What is the GraphQL N+1 problem and why does it happen?
GraphQL executes a query by calling a resolver for each field, and by default each resolver runs independently. A resolver for Post.author receives one post and returns one author. It has no idea that 49 sibling resolvers are about to ask the same kind of question. That isolation keeps resolvers simple and composable, and it is also the cause of the N+1 problem.
Take a query that fetches 50 posts and, for each post, its author's name. The posts resolver runs one query. Then the executor walks the result list and calls the author resolver once per post. Your database log looks like this:
SELECT * FROM posts LIMIT 50SELECT * FROM users WHERE id = 7SELECT * FROM users WHERE id = 12SELECT * FROM users WHERE id = 7- …47 more single-row lookups
That is 51 round trips where 2 would do, and some of them fetch the same row twice. Add another level of nesting, such as each author's recent comments, and the counts multiply.
This article takes two angles on the problem. Batching, typically through DataLoader, changes how resolvers hit the database by collapsing those per-item lookups into one query. Complexity limits bound how much work a client may request at all. You need both. Batching makes a deep query efficient, but a client can still request a deep query.
If your schema has relationships and clients write their own queries, you already have this problem somewhere.
// Naive resolvers: each field resolver runs on its own, with no batching.
// `db.query` stands in for any SQL client that returns rows.
const resolvers = {
Query: {
// Runs once per request: SELECT * FROM posts LIMIT 50
posts: (_parent, _args, { db }) =>
db.query('SELECT * FROM posts LIMIT $1', [50]),
},
Post: {
// Runs once PER POST. It sees one post and cannot tell that
// 49 sibling resolvers are about to run the same kind of lookup.
author: async (post, _args, { db }) => {
const rows = await db.query('SELECT * FROM users WHERE id = $1', [
post.author_id,
]);
return rows[0];
},
},
};
// Client query that triggers the N+1 pattern:
//
// query {
// posts {
// title
// author { name }
// }
// }
//
// Resulting database log for 50 posts:
// SELECT * FROM posts LIMIT 50
// SELECT * FROM users WHERE id = 7
// SELECT * FROM users WHERE id = 12
// SELECT * FROM users WHERE id = 7 <- same row fetched again
// ...47 more single-row lookups
// Total: 1 + 50 = 51 round trips, where 2 would be enough.
Per-field resolvers for a posts -> author query: one list query followed by a separate author lookup for every post, which is the N+1 pattern.
Who should use DataLoader batching and query complexity limits?
Anyone running a GraphQL server whose resolvers fetch related data field by field should use batching. That covers most schemas built on a relational database or on internal services that support bulk lookups by ID. DataLoader is the JavaScript reference implementation. Other server languages have ports or similar batching libraries that follow the same pattern, so the ideas in this article carry over even if the API names differ.
Complexity limits matter most when you do not control the queries:
- Public and partner-facing APIs. Clients write arbitrary operations, and some of them will be expensive by accident or on purpose. These APIs need both batching and an enforced cost budget.
- Internal APIs with many consuming teams. Every team can write its own queries, so you get the same risk with less visibility into who is sending what. Batching plus complexity scoring, at least in log-only mode, is a sensible default.
- A single first-party client on persisted queries. You review each operation before it ships, so batching usually suffices. An allowlist does the job a complexity budget would otherwise do.
You may not need DataLoader if your resolvers already load related data through hand-written joins or selection-set look-ahead, or if the schema has no nested lists. Even then, a query-count test like the one later in this article is cheap insurance against someone adding a per-item resolver later.
How does it work, from request to response?
The two techniques run at different stages of the request lifecycle:
- Parse and validate. The server parses the operation and validates it against the schema, as it would without either technique.
- Score the query. Before any resolver runs, complexity analysis walks the parsed operation, adds up field weights, and multiplies list fields by their requested or default page size. If the total exceeds the budget, the server returns an error that names the cost and the limit, and no database work happens.
- Build the context. For operations that pass, the server creates a fresh set of DataLoader instances for this request only.
- Execute resolvers. Resolvers ask loaders for data by key instead of querying directly. Each
load(key)call returns a promise and enqueues the key. - Dispatch batches. Once the current tick ends, each loader calls its batch function once with all queued keys. One
INquery replaces many single-row lookups, and duplicate keys are fetched once. - Fan out and continue. Results are matched to keys by position. Execution then moves to the next level of the query, where the cycle repeats. That usually means one batched query per type per level.
- Discard. When the response is sent, the loaders and their caches are thrown away along with the request context.
Complexity analysis bounds how much work a request may contain. Batching makes that bounded work run in few round trips. The sections below cover each stage in detail.
How does DataLoader batching work?
DataLoader defers loading. When a resolver calls load(key), it gets back a promise, and the key goes into a queue instead of triggering a query. In the JavaScript reference implementation, the queue is dispatched after the current tick of the event loop. By then, every sibling resolver at that level of the query has run and enqueued its own key. DataLoader then calls your batch function once with the full list of keys and resolves each pending promise with its matching result.
| Step | What happens |
|---|---|
| 1. Resolve phase | Post.author runs for posts 1–4 → load(7), load(3), load(7), load(9) |
| 2. Queue | Keys [7, 3, 9], with the duplicate 7 collapsed by the per-request cache |
| 3. Dispatch | batchFn([7, 3, 9]) → one WHERE id IN (7, 3, 9) query |
| 4. Fan-out | results[0] → key 7, results[1] → key 3, results[2] → key 9; the posts asking for 7 both get results[0] |
The fan-out in step 4 matches results to keys by position only. DataLoader never inspects the returned objects. That gives the batch function a strict contract: return exactly one entry per key, in input order, with null or an Error instance for any key that has no result.
A raw IN query satisfies neither part of the contract. The database can return rows in any order, and it returns no row at all for a missing ID. The reference implementation rejects an array of the wrong length, but it cannot detect wrong order. If the database returns user 3 before user 7, posts written by user 7 show user 3 as their author. Nothing errors, and every field type-checks. The safe pattern is to build a map keyed by ID from the rows, then produce the result array by looking up each input key in that map.
import DataLoader from 'dataloader';
import { db } from './db'; // placeholder: db.query(sql, params) resolves to an array of rows
type User = { id: number; name: string };
// BROKEN: rows come back in whatever order the database picks, and missing
// IDs produce no row. DataLoader rejects a wrong-length array, but it cannot
// detect reordering, so a post by user 7 can silently get user 3 as author.
async function brokenBatch(ids: readonly number[]): Promise<User[]> {
return db.query('SELECT id, name FROM users WHERE id = ANY($1)', [ids]);
}
// CORRECT: index the rows by ID, then walk the input keys in order so that
// result[i] always belongs to ids[i]. A missing key gets an Error in its slot.
async function userBatch(ids: readonly number[]): Promise<(User | Error)[]> {
const rows: User[] = await db.query(
'SELECT id, name FROM users WHERE id = ANY($1)', // Postgres; same idea as IN (...)
[ids],
);
const byId = new Map(rows.map((row) => [row.id, row]));
return ids.map((id) => byId.get(id) ?? new Error(`User ${id} not found`));
}
// Create one loader per request so the dedup cache never leaks across users.
export function createLoaders() {
return { userLoader: new DataLoader<number, User>(userBatch) };
}
// Each Post.author call enqueues one key. DataLoader dispatches a single
// userBatch([7, 3, 9]) call after the current tick and fans results out by position.
export const resolvers = {
Post: {
author: (post: { authorId: number }, _args: unknown, ctx: ReturnType<typeof createLoaders>) =>
ctx.userLoader.load(post.authorId),
},
};
A batch function that returns raw IN-query rows can silently misassign authors, while building an ID-keyed map and walking the input keys returns exactly one result per key, in order, with an Error for each missing ID.
How do you implement DataLoader correctly in a production API?
Create loaders inside the function that builds the GraphQL context, so each request gets a fresh set. A module-level loader looks harmless, but its memoization cache then lives for the life of the process. That causes three problems:
- Wrong caching semantics. DataLoader's cache exists to deduplicate loads within a single request. It is not an application cache, and it has no TTL or eviction policy.
- Authorization leakage. If the batch function filters rows by the caller's permissions, a shared cache returns rows fetched for one user to the next user who asks for the same key.
- Stale data. Rows cached minutes ago get served as current, with no signal that they are out of date.
Batching beyond primary keys
For one-to-many relations, such as the posts for each author, the batch function issues one query with WHERE author_id IN (...). It then groups the child rows by parent key and returns an array of arrays. Parents with no children get an empty array, not a missing slot.
Composite keys, such as (tenant_id, sku), are usually objects. The default cache compares keys by identity, so two equal objects count as different keys. Supply a cache key function that serializes the key into a stable string.
Loaders that take arguments need the same care. Loading comments with a filter and a page size means the cache key must include those arguments. Otherwise a request for the first 10 approved comments can return a cached result for the first 50 of any status. In practice, use a separate loader instance per distinct argument set, because one SQL batch can only apply one filter.
After mutations
When a resolver updates a row, call clear(key) on every loader that may hold it, or prime(key, newValue) with the result the mutation returned. Without this, a mutation payload that selects related fields can read the pre-update value cached earlier in the same request.
import DataLoader from 'dataloader';
type Db = { query<T>(sql: string, params: unknown[]): Promise<T[]> };
type Post = { id: string; author_id: string; title: string };
type Product = { tenant_id: string; sku: string; name: string };
type ProductKey = { tenantId: string; sku: string };
const keyOf = (k: ProductKey) => `${k.tenantId}:${k.sku}`;
// Called once per request: every loader and its cache die with the request.
export function buildContext(db: Db, userId: string) {
// One-to-many: one IN query, group by parent, [] for authors with no posts.
const postsByAuthor = new DataLoader<string, Post[]>(async (authorIds) => {
const rows = await db.query<Post>('SELECT * FROM posts WHERE author_id = ANY($1)', [authorIds]);
const byAuthor = new Map<string, Post[]>();
for (const r of rows) byAuthor.set(r.author_id, [...(byAuthor.get(r.author_id) ?? []), r]);
return authorIds.map((id) => byAuthor.get(id) ?? []);
});
// Composite key: cacheKeyFn makes two equal key objects hit the same cache entry.
const productByKey = new DataLoader<ProductKey, Product | null, string>(async (keys) => {
const rows = await db.query<Product>(
'SELECT * FROM products WHERE (tenant_id, sku) IN (SELECT * FROM unnest($1::text[], $2::text[]))',
[keys.map((k) => k.tenantId), keys.map((k) => k.sku)],
);
const found = new Map(rows.map((r) => [`${r.tenant_id}:${r.sku}`, r]));
return keys.map((k) => found.get(keyOf(k)) ?? null);
}, { cacheKeyFn: keyOf });
return { db, userId, loaders: { postsByAuthor, productByKey } };
}
// In a mutation resolver: evict or replace entries this request may have cached.
export async function renameProduct(ctx: ReturnType<typeof buildContext>, key: ProductKey, name: string) {
const [updated] = await ctx.db.query<Product>(
'UPDATE products SET name = $1 WHERE tenant_id = $2 AND sku = $3 RETURNING *',
[name, key.tenantId, key.sku],
);
// prime() does not overwrite an existing entry, so clear first.
ctx.loaders.productByKey.clear(key).prime(key, updated ?? null);
return updated;
}
A per-request context factory that builds a one-to-many loader grouping posts by author and a composite-key loader with a cacheKeyFn, plus a mutation that clears and re-primes the cached entry so later reads in the same request see the updated row.
How can you detect N+1 queries before they reach production?
N+1 regressions rarely show up as errors. A resolver change that adds one lookup per list item still returns correct data, so functional tests pass and the regression only surfaces once production lists grow long enough to slow requests or saturate the connection pool. To catch it earlier, you need to measure queries, not just results.
Assert query counts per operation in tests
Wrap your database client so it counts statements executed during a single request. Then write integration tests that run representative operations against seeded data and assert an upper bound on that count. The key is seeding enough rows: a list of 2 items cannot tell batched access from per-item access, but a list of 20 can. A test that expects 3 queries and sees 22 fails in CI with a clear signal, and it points to the operation that regressed.
Trace resolver execution
Emit a span per resolver, or at least per field that touches a data source, with database calls as child spans. In a trace, N+1 has a clear shape: a long row of identical, sequential queries under one list field. Batched loading shows up as a single query under the same field. Traces from staging let you see this on realistic data before release.
Log query counts per request
Attach the operation name and total query count to each request log. Compare the distribution between builds in staging, and alert when an operation's count jumps. This costs almost nothing to run, and it catches regressions in operations your tests don't cover. Finding the problem here takes minutes of review, while finding it during an incident means debugging under pressure.
import { graphql } from 'graphql';
import { schema } from '../src/schema'; // your executable schema
import { createLoaders } from '../src/loaders'; // per-request DataLoaders
import { db, seedAuthorsWithPosts } from './fixtures';
// Wrap the db client so every statement in this request is counted.
function countingDb(inner: typeof db) {
let count = 0;
return {
query: (sql: string, params?: unknown[]) => {
count += 1;
return inner.query(sql, params);
},
get count() { return count; },
};
}
test('authorsWithPosts stays batched', async () => {
// Seed 20 authors: with 2, batched and per-item access look alike.
await seedAuthorsWithPosts({ authors: 20, postsPerAuthor: 3 });
const counted = countingDb(db);
const result = await graphql({
schema,
source: '{ authors { id name posts { id title } } }',
contextValue: { db: counted, loaders: createLoaders(counted) },
});
expect(result.errors).toBeUndefined();
// 1 query for authors + 1 batched query for posts.
// An N+1 regression here would show about 21 and fail in CI.
expect(counted.count).toBeLessThanOrEqual(2);
});
An integration test that counts database statements for one GraphQL operation against 20 seeded rows and fails if a resolver change reintroduces per-item queries.
When should you avoid DataLoader, and what are the alternatives?
DataLoader reduces the number of queries, but the total work stays roughly the same. A batch of 5,000 keys becomes one IN list that the database still has to plan and execute. Very large lists can produce slow or unstable query plans and can exceed driver or database bind-parameter limits. Set a maximum batch size so the loader splits large key sets into chunks of a predictable size.
Batching also depends on timing. The loader waits until the current tick ends before it dispatches. If resolvers await unrelated work first, their loads land in different ticks and run as separate batches, so you get fewer round trips than naive resolution but more than you expected. Loaders that take arguments, such as postsByAuthor(limit, status), cause their own problems. Arguments must be part of the cache key. Per-parent limits also don't translate into a single IN query without window functions.
Alternatives
| Approach | Implementation complexity | Schema coupling | Performance profile | Development cost |
|---|---|---|---|---|
| DataLoader | Low to moderate | Low, since resolvers stay independent | Good, with one query per type per tick | Cheap to start, but argument-heavy loaders take ongoing care |
| Hand-written joins | Moderate | High, because the SQL mirrors the query shape | Best for known, hot paths | Each new field may need a query change |
| Selection-set look-ahead | High | High, since resolvers inspect child fields | Near-optimal single queries | Expensive, and fragments and aliases complicate it |
| Denormalized or precomputed fields | Moderate, plus write-path work | Medium | Fastest reads | Ongoing cost for consistency and backfills |
Use DataLoader as the default. Write joins or precomputed fields for the few operations that profiling shows are hot. Use look-ahead only when the query shapes are narrow and stable.
Why isn't depth limiting enough, and how does query complexity analysis work?
Depth limiting caps how many levels a query can nest. That stops recursive abuse such as friends { friends { friends … } }, but it ignores the other two dimensions of cost. A query can stay shallow and still be expensive in two ways. It can be wide, requesting dozens of sibling fields with their own resolvers. It can also pass large list arguments, where first: 1000 at depth 2 costs far more than first: 5 at depth 6.
Complexity analysis scores the parsed query before execution. Each field gets a weight. Scalars are often 0 and object or resolver-backed fields are 1 or more. List fields multiply the cost of their children by the requested size, taken from first, limit or a configured default when the argument is absent. If the total exceeds a budget, the server rejects the operation.
| Check | Measures | Misses |
|---|---|---|
| Depth limit | Nesting levels | Wide selections, large page sizes |
| Complexity analysis | Weighted fields × list sizes | Costs not reflected in weights |
Illustrative estimate. Assume a page size of 100 at each level. Consider users → posts → comments → author, which is only four levels deep:
- users: 100
- posts: 100 × 100 = 10,000
- comments: 10,000 × 100 = 1,000,000
- authors: 1,000,000
That totals about 2 million nodes. With DataLoader, this runs in roughly four batched queries. The round trips are low, but the server still fetches, holds in memory and serializes millions of objects. Batching reduces the number of database calls. It does not reduce how much data a client is allowed to ask for. Only a cost budget enforced before execution bounds that.
How do you enforce query complexity limits without breaking clients?
Compute the score after parsing and validation, before any resolver runs. If an operation exceeds the budget, reject it with a structured error that states the computed cost, the limit, and which fields contributed most. A bare "query too complex" message leaves client teams guessing. A breakdown lets them add pagination arguments or split the operation themselves.
The score is only as reliable as its inputs. Cap first/limit arguments in the schema so list multipliers have a known ceiling, and set a hard execution timeout for anything the static estimate misjudges. For first-party clients, persisted queries or an operation allowlist work better than scoring: you review each operation once, and arbitrary queries never reach the executor.
Static scores are estimates. Weights drift as resolvers change, and a field that was cheap last quarter may now call a slow service. Compare estimated cost against observed latency and database calls, and recalibrate on a schedule. A budget set too strict breaks legitimate dashboards. A budget set too loose gives false confidence. Start in log-only mode, measure the real distribution of costs, then enforce.
Complexity limits do not replace authorization or rate limiting. Feed each operation's cost into a per-client budget so a client that sends many cheap queries is throttled the same way as one that sends a few expensive ones.
A public or partner-facing API needs both batching and complexity limits. A single internal client on persisted queries usually needs only batching. Budget the rollout as a few sprints of observation and tuning, not a one-time configuration change.
How can batching and complexity limits save engineering time?
The main saving is that you stop fixing the same class of problem one field at a time.
- Resolvers stay simple. Without a batching layer, every list field that touches related data needs a custom join or a look-ahead query, and each new field may need that SQL rewritten. With loaders, a resolver is one
load(key)call. Batching is written once per entity and reused by every field that returns that entity. - Regressions are caught in CI, not during incidents. A query-count assertion fails on the pull request that introduced the per-item lookup, and it names the operation that regressed. The alternative is noticing slow requests or an exhausted connection pool in production, then working backwards from traces to the resolver that caused it.
- Expensive queries get rejected with an explanation. A structured complexity error tells the client team which fields drove the cost, so they can add pagination or split the operation without filing a ticket and waiting for the API team to investigate.
- Fewer one-off performance fixes. With an enforced budget and capped page sizes, the worst case for any operation is known in advance. Engineers spend less time defending the server against individual pathological queries, and hand-tuned joins can be kept to the few hot paths that profiling identifies.
- Less time spent on caching bugs. Per-request loaders with explicit
clearandprimecalls after mutations give a single, predictable rule for in-request caching. That replaces ad hoc memoization scattered across resolvers, which is where stale-data and cross-user leakage bugs tend to come from.
The setup cost is real: writing batch functions that respect the ordering contract, seeding test data large enough to expose N+1 patterns, and tuning field weights in log-only mode. That cost is paid once per entity type and once per schema. The cost of not doing it recurs every time someone adds a nested field.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to graphql n+1 dataloader, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.