How per-field resolvers produce N+1 queries
Consider a query that asks for posts(first: 50) and, for each post, its author { name }. The server resolves this in two layers. The posts resolver runs once and returns 50 post objects. The executor then visits each post and calls the author resolver for it, independently, passing that post as the parent. If the author resolver fetches the user by post.authorId, the database sees:
- 1 query to load the 50 posts
- 50 queries to load one author each, often the same handful of authors repeated
That is N+1 round trips, where N is the length of the parent list. Add comments { author } under each post and the count multiplies: each level of list nesting runs its child resolver once per item from the level above.
This is not a coding mistake. It follows from how GraphQL execution works, as described in the execution lesson of the GraphQL documentation. Each field has its own resolver, and that resolver sees only its parent object and arguments. It does not know that 49 sibling resolvers are about to make the same kind of lookup. Resolvers written this way are simple and composable, but the composition is what produces the redundant calls.
So the N+1 problem cannot be fixed only at the resolver level, after the schema is published. Every field that returns an object or list from a list parent is a place where per-item lookups can happen. When you decide which relationships to expose and how deeply they nest, you are also deciding where batching will be required.
// Naive per-field resolvers: correct, simple, and N+1 by construction.
// `db.query` is a placeholder for any SQL client returning rows.
const resolvers = {
Query: {
// Runs once per request: 1 query for the parent list.
posts: (_parent, args, { db }) =>
db.query(
'SELECT id, title, author_id FROM posts ORDER BY id LIMIT $1',
[args.first]
),
},
Post: {
// Runs once PER POST. The executor calls it with each post as `post`;
// it cannot see its 49 siblings, so it issues its own lookup.
author: async (post, _args, { db }) => {
const rows = await db.query(
'SELECT id, name FROM users WHERE id = $1',
[post.author_id]
);
return rows[0];
},
},
};
// For: { posts(first: 50) { title author { name } } }
// the database receives:
// SELECT id, title, author_id FROM posts ORDER BY id LIMIT 50 -- 1
// SELECT id, name FROM users WHERE id = 7 -- 2
// SELECT id, name FROM users WHERE id = 3 -- 3
// SELECT id, name FROM users WHERE id = 7 (same author again) -- 4
// ... one per post ... -- 51
//
// What a batched approach would send instead (2 queries total):
// SELECT id, title, author_id FROM posts ORDER BY id LIMIT 50
// SELECT id, name FROM users WHERE id = ANY($1) -- $1 = distinct author ids
Straightforward per-field resolvers for posts and their authors issue one author query per post, producing 51 database calls for a 50-post list where two would suffice.
Why schema shape decides where N+1 can occur
Consider a Post type that references its author. You can expose the relationship as authorId: ID! or as author: User!. The ID version never triggers an extra lookup during execution, because resolving it means reading a column already on the row. The object version gives every Post in a list its own author resolver call, which is exactly the per-item lookup pattern that produces N+1.
Exposing raw IDs only moves the cost elsewhere. Clients must make a second request to resolve those IDs, stitch the results together themselves, and repeat that logic in every consumer. The schema also stops describing the graph. Tools and developers can no longer see from the types that a post has an author. Later changes get harder too. If authorship becomes a list of co-authors, an object field can be deprecated next to a new authors field. A bare ID offers no natural path for that change.
| Aspect | authorId: ID |
author: User |
|---|---|---|
| Server lookups per list | None extra | One per item unless batched |
| Client round trips | Extra request to resolve IDs | One query |
| Schema expresses relationships | No | Yes |
| Room to evolve the field | Limited | Deprecate and add alongside |
Object fields remain the better design. Treat each one as a place that needs batching. This matters most for teams with deeply relational data, such as orders, line items, products and inventory, and for APIs that serve several clients with different query shapes. The concern is minor when the schema is mostly flat or when typical queries fetch a single object by ID. In those cases, each nested field resolves once per request, not once per list item.
How DataLoader batching works and how to implement it
When nesting is deep and lists are long, the fix is to stop resolving each field independently. DataLoader does this by turning many individual load(key) calls into one batched fetch.
The mechanics
Each Author.posts or Post.author resolver calls loader.load(id) and receives a promise. The loader does not fetch anything yet. It collects the keys requested during the current tick of execution. Sibling resolvers in a list run in that same tick, so their keys land together. After the tick, the loader calls your batch function once with the full key array, for example to run a single WHERE id IN (...) query.
The batch function has one strict contract. It must return an array with the same length and order as the input keys. Databases return rows in arbitrary order and skip missing ones, so build a map from ID to row, then walk the original key list. Return null for a key that has no row. Return an Error instance for a key whose fetch failed, so only that field errors instead of the whole batch.
Scoping
Create loaders per request, inside the context factory. Each loader memoizes results by key. A loader shared across requests would serve one user's authorized rows to another user and would keep stale data indefinitely.
Pitfalls
If a resolver awaits unrelated work before calling load, such as a remote permission check, its key arrives after the batch has already been dispatched. That key then goes out in a separate query. Nothing errors, and you only notice when you count queries. Stack traces also end at the loader's dispatch rather than at the calling resolver, so new engineers need to learn this indirection before they can debug it.
import DataLoader from "dataloader";
import { Pool } from "pg";
const pool = new Pool(); // connection settings come from PG* env vars
type Author = { id: string; name: string };
const UUID = /^[0-9a-f-]{36}$/i;
// Called once per tick with every key that resolvers passed to load().
async function batchAuthors(ids: readonly string[]): Promise<(Author | Error | null)[]> {
const { rows } = await pool.query<Author>(
"SELECT id, name FROM authors WHERE id = ANY($1)",
[ids.filter((id) => UUID.test(id))]
);
// Rows arrive in arbitrary order with gaps. Re-align them to the input keys.
const byId = new Map(rows.map((r) => [r.id, r]));
return ids.map((id) => {
if (!UUID.test(id)) return new Error(`Invalid author id: ${id}`); // only this field errors
return byId.get(id) ?? null; // a missing row resolves to null
});
}
// Build loaders per request so the memo cache never crosses users or auth contexts.
export function createContext({ req }: { req: { user?: { id: string } } }) {
return {
user: req.user,
loaders: { author: new DataLoader(batchAuthors) },
};
}
type Ctx = ReturnType<typeof createContext>;
export const resolvers = {
Post: {
// Call load() directly. Awaiting unrelated work first (for example a remote
// permission check) pushes this key past the dispatch, and it goes out in
// a separate query with no error to tell you.
author: (post: { authorId: string }, _args: unknown, ctx: Ctx) =>
ctx.loaders.author.load(post.authorId),
},
};
A DataLoader batch function that runs one ANY($1) query, returns results in key order with null for missing rows and an Error for an invalid key, and gets a new loader for each request in the context factory.
Batching paginated, filtered and large child fields
A loader keyed only by parent ID assumes every caller wants the same thing for that ID. That assumption fails once a child field takes arguments. If one part of a query asks for posts(first: 3) on each author and another asks for posts(first: 10, status: DRAFT), both calls carry the same author ID but need different results. Caching them under one key returns wrong data.
The usual fix is a composite key that combines the parent ID with a normalized form of the arguments, such as sorted filter values, page size and sort order. Keep one loader per argument set, or one loader whose batch function groups keys by their argument portion. Each group then becomes one query.
Pagination per parent is harder. LIMIT 3 applies to the whole result set, not to each author. A window function solves this. Select posts WHERE author_id IN (...), compute ROW_NUMBER() OVER (PARTITION BY author_id ORDER BY created_at DESC), and keep rows where that number is at most 3. The result is one round trip with a correct limit for each parent. Cursor pagination works the same way if you add the cursor condition before ranking. Check that an index covers (author_id, created_at). Without one, the database may sort far more rows than it returns.
Very large key sets cause separate problems. Some drivers and databases cap the number of bind parameters or the length of an IN list. Very long lists can also push the planner away from index lookups. To handle this, chunk keys into fixed-size batches and run the chunks concurrently, keeping total concurrency within your connection pool. Alternatively, pass the keys as a single array parameter and join against it. Pick the chunk size by measuring against your own database instead of guessing.
import DataLoader from "dataloader";
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
type PostsKey = { authorId: number; first: number; status: string };
// Composite cache key: parent ID plus normalized arguments.
function cacheKey(k: PostsKey): string {
return `${k.authorId}:${k.status}:${k.first}`;
}
// One round trip with a per-author limit. Keys go in as a single array
// parameter, so the bind-parameter count stays at 3.
// Needs an index on (author_id, created_at).
const SQL = `
SELECT * FROM (
SELECT p.*, ROW_NUMBER() OVER (
PARTITION BY p.author_id ORDER BY p.created_at DESC) AS rn
FROM posts p
WHERE p.author_id = ANY($1::int[]) AND p.status = $2
) ranked
WHERE rn <= $3
ORDER BY author_id, rn`;
export const postsLoader = new DataLoader<PostsKey, any[], string>(
async (keys) => {
// Group keys by their argument portion. Each group becomes one query.
const groups = new Map<string, PostsKey[]>();
for (const k of keys) {
const g = `${k.status}:${k.first}`;
groups.set(g, [...(groups.get(g) ?? []), k]);
}
const results = new Map<string, any[]>();
await Promise.all([...groups.values()].map(async (group) => {
const { status, first } = group[0];
const ids = group.map((k) => k.authorId);
const { rows } = await pool.query(SQL, [ids, status, first]);
for (const id of ids) results.set(cacheKey({ authorId: id, status, first }), []);
for (const r of rows) results.get(cacheKey({ authorId: r.author_id, status, first }))!.push(r);
}));
return keys.map((k) => results.get(cacheKey(k))!);
},
// maxBatchSize splits very large key sets into chunks. Tune it by measuring.
{ cacheKeyFn: cacheKey, maxBatchSize: 500 }
);
A DataLoader keyed by author ID plus normalized arguments that groups keys by argument set, applies a ROW_NUMBER() window function to enforce a per-author limit in one query, passes IDs as a single array parameter, and caps batch size to chunk very large key sets.
Nullability and schema evolution in a versionless API
When a batched loader resolves 100 keys and one row is missing or one backend call times out, the loader can return an error for that key alone. What the client sees depends on the field's nullability. On a nullable field, the executor sets that one value to null, adds an entry to the errors array with its path, and returns the other 99 results. On a non-null field, the null propagates upward to the nearest nullable ancestor. That can blank out a whole list or the entire data payload.
| Aspect | Nullable output field | Non-null output field |
|---|---|---|
| Single key fails | That field is null, siblings survive |
Null bubbles to nearest nullable parent |
| Later change | Tightening to non-null is non-breaking | Loosening to nullable breaks clients that assume a value |
| Client burden | Must handle null and read errors |
Simpler types, larger failure radius |
| Visibility | Partial failures look like valid responses | Failures are loud |
Nullable fields have costs. Every client must branch on null and check errors. A response with HTTP 200 and a few nulled fields is easy to miss, so count resolver errors per field in your metrics instead of relying on status codes.
GraphQL avoids versioning by evolving the schema in place:
- Add fields, types and arguments rather than changing existing ones.
- Mark fields with
@deprecated(reason: ...)instead of removing them. - Build clients to tolerate unknown enum values and new union members.
Deprecated fields still need resolvers, loaders and tests until you remove them. Removing one safely requires per-field usage data from operation logs and coordination with the teams that own the remaining callers. The GraphQL best practices on schema design and robust applications cover these trade-offs further.
import DataLoader from "dataloader";
export const typeDefs = /* GraphQL */ `
type Order {
id: ID!
# Nullable: if one customer lookup fails, only this field is null
customer: Customer
# Kept for older clients; still needs a resolver until usage reaches zero
customerName: String @deprecated(reason: "Use customer { name }.")
# Clients should handle enum values they do not recognize
status: OrderStatus!
}
type Customer { id: ID! name: String! }
enum OrderStatus { PENDING SHIPPED DELIVERED }
`;
type Customer = { id: string; name: string };
type Order = { id: string; customerId: string; status: string };
declare function fetchCustomers(ids: readonly string[]): Promise<Customer[]>;
// Create one loader per request so cached results are not shared across users.
export function createCustomerLoader() {
return new DataLoader<string, Customer>(async (ids) => {
const rows = await fetchCustomers(ids); // one query for all keys
const byId = new Map(rows.map((c) => [c.id, c]));
// Return one entry per key, in key order. An Error rejects only that
// key's load(), so the other keys still resolve.
return ids.map((id) => byId.get(id) ?? new Error(`Customer ${id} not found`));
});
}
export const resolvers = {
Order: {
customer: (o: Order, _: unknown, ctx: { customers: DataLoader<string, Customer> }) =>
ctx.customers.load(o.customerId),
// Deprecated field reuses the same loader until it can be removed
customerName: async (o: Order, _: unknown, ctx: { customers: DataLoader<string, Customer> }) =>
(await ctx.customers.load(o.customerId)).name,
},
};
A DataLoader batch function returns an Error for a missing key. Because Order.customer is nullable, only that order's customer comes back as null with an entry in errors, and the deprecated customerName field stays resolvable until callers have migrated.
Detecting N+1 and limiting expensive queries in production
Code review is a poor N+1 detector because the regression rarely appears in the diff. A new field on a type that sits inside a list looks harmless in isolation, and the extra queries only show up when a client happens to select it. Make the behaviour measurable instead. Wrap each data source (database client, HTTP client, cache) with a counter scoped to the request, then write tests that execute a representative operation against fixtures of two different sizes. If the call count grows with the number of parent records instead of staying constant, the test fails. The same counter, attached to traces as a span attribute or emitted as a per-operation metric, catches cases your fixtures did not anticipate.
Bounding total work
Batching collapses many calls into few, but a query that touches a million rows still touches a million rows. Depth limits stop pathological nesting. Complexity or cost analysis assigns a weight to each field, typically multiplied by the requested page size on list fields. It rejects an operation before execution if the total exceeds a budget. Pair these with server-side caps on pagination arguments and execution timeouts. The performance and security guidance covers these controls.
Calibrating thresholds
A limit chosen without measurement will eventually reject a legitimate dashboard query. You will usually find out from an incident. Start in report-only mode. Compute the depth and cost of every operation, log them alongside the operation name, and leave enforcement off. After observing real traffic, set the budget above the highest legitimate value with some headroom. Review the outliers individually, then enforce. Recompute when major clients ship new screens.
Alternatives, when to avoid DataLoader, and impact on engineering cost
DataLoader is one way to collapse per-field lookups. Several other techniques address the same cost at a different layer, and each trades one problem for another.
| Approach | How it avoids N+1 | Main cost |
|---|---|---|
| Look-ahead joins | Reads the selection set and builds one query with joins | Couples resolvers to storage; deep or conditional selections produce large, hard-to-tune joins |
| Precomputed or denormalized fields | Stores counts or summaries on the parent record | More write-path logic and possible staleness |
| Persisted queries | Server runs only known operations, so each can be optimized individually | Clients lose ad hoc queries; resolver code is unchanged |
| Shared cache | Serves repeated reads across requests | Invalidation; cache keys must include viewer or permission scope, or one user's data reaches another |
Batching is unnecessary when a field reads data already loaded on the parent, when a list is reliably capped at a few items, or for a single root lookup. It is counterproductive when the backend has no batch endpoint, because the loader still issues N calls and adds a scheduling delay. It also adds indirection: stack traces point at the batch function rather than the field that requested the data, which slows debugging.
The cost question is timing. The formula is: upfront cost = relationship fields × hours per loader and test, and retrofit cost = fields found in production × hours per fix.
Illustrative estimate (assumed numbers, not a benchmark): Assume 15 relationship fields at 2 hours each upfront, which totals 30 hours. Now assume 8 fields are instead fixed after launch at 6 hours each to locate, refactor, retest and deploy. That totals 48 hours, plus incident response time.
Call-budget tests keep the upfront investment from eroding as the code changes. Nullable, additive fields let you change a field's resolution strategy later without a breaking change.
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 schema design, 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.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
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.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- 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.