Skip to content
intermediate Phase 9 · Solving the N+1 Problem with DataLoader

The N+1 Query Problem

Recognizing how nested GraphQL resolvers trigger one database query per parent row.

45m
1 problems
Topic Progress 0%

The Query Storm in Slow Motion

The Query Storm in Slow Motion

A client asks for a page of posts and the people who wrote them:

query {
  posts(first: 25) {
    title
    author { id name }
  }
}

The resolvers look innocent:

const resolvers = {
  Query: {
    posts: (_, { first }) => db.query('SELECT * FROM posts LIMIT $1', [first]),
  },
  Post: {
    author: (post) => db.query('SELECT * FROM users WHERE id = $1', [post.authorId]),
  },
};

The root resolver runs once: one statement, twenty-five rows. Then GraphQL's execution algorithm walks that list and calls Post.author once per item, each invocation receiving exactly one parent object. Inside that loop no resolver can see the other twenty-four rows, so each one opens its own round trip:

Resolver level Statements
Query.posts 1
Post.author × 25 25
Total 26 = 1 + N

Ten posts gives 11. Ten thousand gives 10001. Notice what drives the number: not the shape of the document, not its depth, not the number of fields selected — the row count returned by the level above.

That is the N+1 problem: one statement to fetch the parents, then one statement per parent to fetch its children. The document itself never changes as data grows, which is why this surfaces in production as a latency cliff six months after launch rather than as a bug anyone wrote deliberately. The two clients sending byte-identical queries get wildly different costs depending on how many rows happen to be on the page:

Page size Statements Latency added (1ms per query)
10 rows 11 ~10ms
50 rows 51 ~50ms
200 rows 201 ~200ms
1000 rows (an export) 1001 ~1 second

Two properties make this pattern easy to misdiagnose. First, it is not tied to any particular field: any field at depth 2 or deeper that fetches per parent is a candidate, so Post.author, Comment.post, and Order.lineItems each qualify independently inside one document. Second, execution proceeds level by level — GraphQL finishes every author resolver for the list before moving deeper — so the trace shows a tight block of identical statements at exactly one point in the request rather than scattered noise. That block is the fingerprint you look for.

Why the Fix Belongs in the Data Layer

Why the Fix Belongs in the Data Layer

The reflex is to fix the client document. Deleting author from the selection set does stop the storm — and the feature with it. Aliases and fragments reshape the response, not the execution: duplicating a field under two aliases runs the resolver twice, doubling the statements for the same data.

N+1 also compounds with depth, multiplying at every level:

query {
  authors(first: 10) {          # 1 statement
    posts(first: 5) {           # 10 statements  (one per author)
      comments(first: 3) {      # 50 statements  (one per post)
        author { name }         # 150 statements (one per comment)
      }
    }
  }
}

211 statements to render one page, and each level's fan-out multiplies everything above it. Because the amplification lives inside resolver execution, only the data layer can remove it. Three real options:

  • A join. Collapse the levels into one SQL statement and let the resolver shape the nested rows. Cheapest when the response shape is fixed and the nesting is shallow, but every new nesting level rewrites the query.
  • A batch loader. Fetch all children of a level in one statement keyed by parent ids. The schema stays independent of the database shape — see DataLoader configuration for the mechanism and manual batch loading for doing it without a library.
  • A denormalized read model. Store the joined shape when reads dominate and the join itself has become the bottleneck.

All three share one property: the client's document is untouched, because the client never caused the problem. The resolver boundary did — the same boundary that decides how many rows cross into the next level. A useful habit when reviewing a schema: for every field that returns a list, ask what statement runs once per item, and count it.

Spotting N+1 in Traces and Logs

Spotting N+1 in Traces and Logs

Every individual statement in an N+1 storm is fast and well indexed. The failure is repetition, so detection is about counting, not tuning. Three reliable signals:

Repeated statement text. In Postgres, pg_stat_statements and the request log show the same normalized statement over and over inside one request span:

SELECT * FROM users WHERE id = $1   -- 25 times within 400ms

If the only thing changing between executions is the bind value, something is looping over rows.

A flat ladder in the trace. An APM waterfall for a single GraphQL operation shows dozens of sequential database spans of near-identical duration. A healthy batched operation shows one wide span where the ladder used to be.

A per-request statement counter. Instrument the driver for one operation:

let statements = 0;
db.on('query', () => { statements += 1; });
// run the operation, then log statements alongside the operation name

Twenty-six statements for one document is the tell; two is the fix.

Two things N+1 is not. It is not a slow query — each statement is fast in isolation, so the slow-query log stays quiet while latency grows. And it is not a depth violation: a document of depth 2 can still execute thousands of statements, so depth limiting will not catch it. These are independent guards — depth and complexity limits bound what a document may ask for (depth limits), batching bounds what one document costs the database. Protect both.

Interview framing that lands: give the count before and after ("1 + 25 collapsed to 2"), name the mechanism that collapsed it, and say how you would prove it on a live system (statement counter or a trace diff across the deploy).

Practice Problems

0 / 1 solved
Count the Statements in a Request Trace
N+1 Diagnosis

A request trace for one GraphQL operation shows a query to posts, followed by repeated queries to users. Given the operation and the trace, compute the total number of database statements the server executes, then state what the count becomes after the author loads are batched.

Example:

Input: posts(first: 25) { title author { name } } — trace: 1 × SELECT * FROM posts, then 25 × SELECT * FROM users WHERE id = $1

Output: 26 statements; 2 after batching

The root resolver runs once, then Post.author runs once per row: 1 + 25 = 26. Batching collapses the 25 user statements into one, leaving the parent query plus one batch query.

Optimal Solution — O(1) time, O(1) space

Count one statement per resolver invocation: root resolver once, per-row resolver once per row, then collapse the per-row group when loads are batched

function countStatements(document, rowCount) {
  // Query.posts -> 1 statement
  const parentQueries = 1;
  // Post.author -> 1 statement per row when unbatched
  const unbatched = parentQueries + rowCount;
  // After batching: 1 statement for the whole batch of ids
  const batched = parentQueries + (rowCount > 0 ? 1 : 0);
  return { unbatched, batched };
}

countStatements('posts(first: 25) { author }', 25);
// { unbatched: 26, batched: 2 }

Edge Cases:

  • Zero parent rows: only the parent statement runs, batched or not
  • Duplicate author ids across rows: unbatched count still grows with rows, batching deduplicates keys first
  • A child field that resolves from the parent object without a query adds no statements

Quiz

1. A root resolver returns 200 rows and each row triggers a child resolver that queries the database. How many statements does the operation run?

Question 1 options

2. Which change reduces the database statement count for a nested document?

Question 2 options

3. What most directly identifies an N+1 problem in a query trace?

Question 3 options

Flashcards

Question

N+1 query pattern

Answer

One statement for the parent list plus one statement per row when children resolve individually: 1 + N total.

Question

Where N+1 originates

Answer

Resolver execution: each field resolver receives a single parent object, so per-row data needs per-row queries.

Question

Why aliases do not fix N+1

Answer

They rename response keys; resolvers still run once per row, so statement count stays the same or doubles.

Question

Depth limit vs batching

Answer

Depth/complexity limits bound what a document may ask for; batching bounds what it costs the database. Both are needed.

Question

Signature of N+1 in logs

Answer

The same normalized SQL executing repeatedly inside one request span, only the bind parameter changing.

Revision Notes

Key Takeaways

  • 1. Statement count is 1 + N where N is the parent row count, independent of how the document is written
  • 2. Fan-out multiplies at every nesting level, so a depth-3 document can exceed 200 statements for 10 parents
  • 3. Only the data layer removes the pattern: joins for fixed shapes, batch loaders for flexible ones

Interview Tips

  • • Quote concrete counts before and after the fix — "1 + 25 became 2" persuades where adjectives do not
  • • Separate depth limiting from batching: one bounds document cost, the other bounds database fan-out, and a depth-2 document can still be an N+1 storm

Cheat Sheet

N+1 Quick Reference

Shape: 1 parent statement + 1 statement per row → 1 + N

Count for posts(first: 25) { author }: 1 + 25 = 26

Three-level doc: authors(10) → posts(10) → comments(50) → author(150) = 211

Log signature: same normalized SQL, only the bind value changes

Not fixes: aliases, fragments, client-side field removal, bigger pages (worse)

Fix order: join for fixed shape → batch loader for flexible shape → denormalized read for hot paths

Two independent guards: depth/complexity rules bound the document; batching bounds the database

Proof: per-request statement counter, or diff the APM waterfall across the deploy

Continue Learning