GraphQL DataLoader: Fix Ordering, Missing Rows and Leaks13 min read2,534 words

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.

GraphQL DataLoaderDataLoader batch functionGraphQL N+1 problemDataLoader orderingper-request DataLoader
GraphQL DataLoader: Fix Ordering, Missing Rows and Leaks

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 n corresponds to input key position n.

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:

  1. Query all records matching the requested keys.
  2. Index those records by the key field.
  3. 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.

Where should loaders and authorization checks live?

A DataLoader has a cache. Within a single GraphQL request, that cache is usually a win: multiple resolvers asking for the same user can reuse one database result instead of repeating the query.

Out of context, though, the cache can become a correctness or security problem. If it outlives the request (or ignores the access context), you can reuse data that should not be shared.

Create loaders per request

A module-level loader lives for the lifetime of the process. If it caches a user fetched during one request, a later request can reuse it. That might mean stale data, or it might mean crossing a boundary where data shouldn't be shared.

Create loaders inside the function that builds your GraphQL request context:

context: () => ({

db,

loaders: createLoaders(db),

})

The framework hook varies, but the principle is consistent: share a loader among resolvers in the same request, not across unrelated requests. That gives you request-level deduplication without a process-wide cache quietly accumulating data.

Keep access policy aligned with cache identity

A loader keyed only by user ID stores one answer for that ID in its cache. If the batch result depends on who is asking (viewer permissions, tenant rules, roles), that cached value might not be reusable across access contexts.

You can handle this in a couple ways:

  • Keep the loader request-scoped, fetch the record, and apply field-level access policy in the resolver.
  • If the database query itself must enforce tenant-scoping, tie the loader (or its cache key) to that scope - don't assume a bare ID is enough to uniquely identify an authorization-safe result.

The underlying rule is simple: make your cache boundary line up with your data boundary. A per-request cache is not automatically safe if one request can include multiple viewers or access scopes. And moving authorization into the batch query is not automatically unsafe if your loader's key design includes the right scope. The right question is always: what does the cached value mean, who can reuse it, and for how long?

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.

Frequently Asked Questions

No. `WHERE id = ANY(...)` filters for matching rows but does not guarantee the output order. If order matters, specify it in SQL for the relevant semantics or map rows back to the original keys in the batch function. For DataLoader, key-to-result mapping is still required.
It depends on the field contract. Use `null` when absence is a valid result for that relationship. Use a clear error when the relationship is required or the caller needs failure semantics. In either case, return a result for that key so one missing row does not invalidate the whole batch.
Usually request-scoped. This allows resolvers within one request to share cached loads while preventing a cache from being reused indefinitely across unrelated requests. If results depend on tenant or viewer scope, make sure the loader’s lifetime and cache key reflect that boundary too.
No. A lower count shows that requests were batched, not that results were assigned to the correct keys. Test the relationship between each parent and child, use non-insertion-order data, and include missing-row behavior. When possible, run integration coverage against the database you use in production.

Related articles