GraphQL DataLoader: Fix Ordering, Missing Rows and Leaks
Learn how to preserve DataLoader key order, handle missing rows safely, scope caches per request and test GraphQL relationships against real Postgres.

Your DataLoader Fixed the N+1. Did It Change the Authors?
You spot an N+1 query in your GraphQL API: fetch 25 posts with one query, then fetch each author with another - so you end up doing 26 queries total. Then you add DataLoader, and the query count drops from 26 to 2. It feels like you're done.
But fewer queries don't automatically mean correct data. A batch function might return rows in an order that doesn't match the keys DataLoader asked for. DataLoader will still zip results to keys by position, whether the rows actually belong there or not. If a batch returns too few rows (say, one related record is missing), that can also cause the whole batch to fail for every item. And if you reuse a DataLoader instance beyond a request (or without the right scoping), you can end up serving stale or wrongly scoped data.
These issues are sneaky. The code can pass "normal" tests and still produce GraphQL responses that look plausible. This guide lays out the batch function's contract, shows safe patterns for one-to-one and one-to-many relationships, and covers loader scope, authorization, and testing. The goal isn't just fewer queries - it's preserving the relationship each resolver actually asked for.
What does a DataLoader batch function have to return?
A DataLoader batch function does more than "fetch some records." It has to return results for every requested key, in the same order as the keys came in. Concretely:
- The output array length must match the input array length.
- Result position
ncorresponds to input key positionn.
For example, if resolvers request users in this order:
[u3, u1, u2]
then your batch function must return:
[user3, user1, user2]
DataLoader doesn't look at each returned object's id to figure out where it belongs. It assumes your batch function already restored the mapping between keys and results. So it's on you to do that mapping.
Why returning query rows directly is unsafe
A common first attempt sends all requested IDs to one query and returns the rows directly:
const userById = new DataLoader<string, User>(async (ids) => {
const { rows } = await db.query<User>(
'SELECT * FROM users WHERE id = ANY($1::uuid[])',
[ids],
);
return rows;
});
This looks fine because the SQL fetches the right set of users in one round trip. The trap is that "these rows were requested" is not the same as "these rows are in request order."
SQL returns a set of matching rows. The batch function has to produce an ordered array that matches the original key order - slot by slot.
A safer pattern is to map rows by their key and then walk the input keys:
function byKey<T, K>(
keys: readonly K[],
rows: readonly T[],
keyOf: (row: T) => K,
): (T | null)[] {
const map = new Map(rows.map((row) => [keyOf(row), row]));
return keys.map((key) => map.get(key) ?? null);
}
Now the returned array follows the input key sequence. And notice what else this preserves: one output slot per input key - even when a record doesn't exist. That "missing results" behavior matters just as much as ordering.
Treat the contract as correctness, not cleanup
When you review a loader, don't only ask, "Does it fetch the right rows?" Ask instead:
Can I prove that output position n matches input key n, including when a key has no row?
If your implementation maps by key and then iterates over the input keys, that proof is baked into the code. It's small, but it protects every resolver that uses the loader.
Why can a database query return rows in the wrong order?
Unless your SQL includes an ORDER BY, SQL doesn't promise any particular row order. A filter like:
WHERE id = ANY($1::uuid[])
only tells Postgres which rows match the IDs you provided. It does not instruct Postgres to return those rows in the same order as the IDs appeared in the array.
The order you get can change based on the query plan. A sequential scan might produce one ordering; an index scan might produce another. Data size, distribution, updates, or planner estimates can also change which plan Postgres picks. None of those choices has to align with your input sequence.
Why this can look correct in development
In small test databases, you can get lucky. If the inserted rows happen to line up with the order your test requests them, the query might coincidentally return rows in the same order. Your test passes - without ever proving the DataLoader mapping contract.
In production, that luck runs out. The query can still return valid user rows, but DataLoader will attach them to the wrong posts. One post that asked for u3 might get u1, and another might get u2. Nothing necessarily crashes. The response shape is still valid, and the names can look "reasonable," which makes the bug harder to notice than an obvious exception.
And adding ORDER BY in SQL usually isn't the right fix for this specific problem. Ordering by ID gives you "sorted by ID," not "sorted by the caller's input array order." You can write SQL that preserves the input order, but you still need to handle missing keys, and the ordering logic can get database-specific or harder to maintain.
Restore order in application code
A straightforward batch pattern looks like this:
- Query all records matching the requested keys.
- Index those records by the key field.
- Build the output array by iterating over the original keys.
const userById = new DataLoader<string, User | null>(async (ids) => {
const { rows } = await db.query<User>(
'SELECT * FROM users WHERE id = ANY($1::uuid[])',
[ids],
);
return byKey(ids, rows, (row) => row.id);
});
This keeps correctness independent of whatever row order Postgres chooses, and it leaves the SQL focused on fetching matches rather than trying to satisfy a positional contract.
How should a batch handle missing rows and collections?
A batch can return fewer rows than requested. A post might reference a deleted user, the data might be incomplete, or some keys might simply have no match. If you return database rows directly, you can break the key-to-result length/position contract. DataLoader will then treat the mismatch as a failed batch - so one missing record can blow up every key in that batch.
How you handle "missing" depends on what the relationship means in your schema.
For a singular relationship, return one output per key and represent absence explicitly. In the earlier example, the loader returns User | null. Then the GraphQL schema and resolver decide whether that missing author becomes a nullable field, a domain error, or something else.
Do not let one missing record invalidate the batch
const userById = new DataLoader<string, User | null>(async (ids) => {
const { rows } = await db.query<User>(
'SELECT * FROM users WHERE id = ANY($1::uuid[])',
[ids],
);
return byKey(ids, rows, (row) => row.id);
});
Because the batch returns exactly one result per requested ID, a missing record becomes null in its own position. It won't shift later values or reject unrelated lookups.
What null means is a product and schema decision. If a missing author is expected, a nullable field may be fine. If the relationship "should always exist," your resolver can raise a clear error or your team can treat it as an integrity issue. The batch function's job is to preserve facts and ordering, not to invent policy for every caller.
Group rows for one-to-many relationships
Collections have a different shape. For a field like Order.items, you group rows by the parent key and return an array per requested parent. If an order has no items, it should get [] - not a missing result.
const itemsByOrderId = new DataLoader<string, OrderItem[]>(async (orderIds) => {
const { rows } = await db.query<OrderItem>(
'SELECT * FROM order_items WHERE order_id = ANY($1::uuid[]) ORDER BY created_at',
[orderIds],
);
const groups = new Map<string, OrderItem[]>();
for (const row of rows) {
const items = groups.get(row.order_id) ?? [];
items.push(row);
groups.set(row.order_id, items);
}
return orderIds.map((id) => groups.get(id) ?? []);
});
Here, ORDER BY created_at controls the order within each order's items. Meanwhile, orderIds.map(...) preserves the original batch key order (one output array per requested order). Treat those as separate concerns: SQL ordering helps with collection semantics, but you still need to map groups back to the requested keys.
How do you test that DataLoader returns the right relationships?
A query-count test is useful, but it proves only that batching happened. It doesn't prove that each result was paired with the correct key. Worse, you can write a mock-based test that returns rows in the exact order your code expects - so it can "confirm" the same broken assumption that causes the production bug.
Instead, add an integration test that sets up relationships in an order different from the API's request order. Then assert the relationship itself - not just the response shape or number of queries.
it('returns each post with its own author', async () => {
const users = await createUsers(5);
// Posts reference users in reverse insertion order.
for (const user of [...users].reverse()) {
await createPost({ authorId: user.id });
}
const response = await execute(`
{ posts(first: 5) { authorId author { id } } }
`);
for (const post of response.data.posts) {
expect(post.author.id).toBe(post.authorId);
}
});
Run this kind of test against real Postgres when you can. A mocked db.query can check how your code handles a specific row sequence, but it can't guarantee what the database will do in real life. The valuable test is the one that forces the mapping to work even when the insertion/order assumptions don't hold.
A practical DataLoader review checklist
Before shipping a loader, verify:
- The batch returns one result per input key, in input order.
- You map rows back to keys; you don't return query rows blindly.
- Missing singular records become
null(or an explicit per-key error). - Missing collections become
[]. - Collection ordering is defined where the product needs it.
- Loaders are created per request, and cache scope matches authorization scope.
- Tests verify the relationship for each item, including non-insertion order and missing-row cases.
- Query-count tests stay, but they're treated as performance checks - not proof of correctness.
The key distinction is between an optimization test and a correctness test. You want both. The first makes sure the N+1 is gone; the second makes sure batching didn't change what the API means.
Conclusion
DataLoader fixes the N+1 problem by batching lookups, but batching only works if you uphold the positional contract. You need to map query results back to requested keys, provide one output slot per input key, and make sure collection loaders return a group for every parent.
Keep caches within the right request and access scope, and test relationships rather than query counts alone. The rule of thumb is straightforward: if your batch function can't clearly show how each input key maps to its output value, it's not finished. Add that mapping before trusting the performance win.