You found an N+1 in your GraphQL API: a list of 25 posts, each Post.author resolver running its own SELECT. You added a DataLoader, the query count dropped from 26 to 2, and you moved on.

Here is the batch function most people write first:

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; }); 

It passes every test you throw at it. It is also wrong in two ways, and one of them is silent.

Bug 1: Postgres doesn't return rows in the order you asked

DataLoader has one contract: the batch function returns an array in the same order and of the same length as ids. Result 0 goes to whoever asked for ids[0], result 1 to ids[1], and so on. DataLoader never looks inside the objects to check.

WHERE id = ANY($1) makes no promise about order. Postgres returns rows in whatever order its plan produces them: heap order for a sequential scan, index order for an index scan. Neither is the order of your array.

So if DataLoader asks for [u3, u1, u2] and Postgres returns [u1, u2, u3], the post that asked for u3 gets u1, and every other author shifts too. No error, no warning, valid-looking data.

Why your tests didn't catch it: in a test database with three freshly inserted users, a small sequential scan usually returns them in insertion order, and your test probably requests them in that same order. In production, with updates moving rows around and the planner picking an index scan, the order drifts.

Bug 2: one missing row breaks the whole batch

Say one post references a deleted user. Now Postgres returns 24 rows for 25 ids. DataLoader checks the length, sees a mismatch, and rejects every key in the batch. One dangling foreign key turns into 25 errors and a list that won't render.

The fix: map rows back to keys

function byKey<T, K>(keys: readonly K[], rows: T[], keyOf: (row: T) => K): (T | null)[] { const map = new Map(rows.map((r) => [keyOf(r), r])); return keys.map((k) => map.get(k) ?? null); } 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, (r) => r.id); }); 

Same order as ids, same length, null for a missing row. The field resolver decides what null means (a nullable author field, or a NOT_FOUND error) instead of the whole batch failing.

For one-to-many fields (Order.items), group instead of map, and return [] for keys with no rows:

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 list = groups.get(row.order_id) ?? []; list.push(row); groups.set(row.order_id, list); } return orderIds.map((id) => groups.get(id) ?? []); }); 

Two more ways DataLoader leaks data

A loader at module level. DataLoader caches every key it loads, forever, for the life of the loader. Create it once at import time and user A's request warms a cache that user B's request reads from: stale rows at best, rows B shouldn't see at worst. Create loaders in the per-request context factory:

context: () => ({ db, loaders: createLoaders(db) }) 

Authorization inside the batch function. A batch mixes keys from every resolver in the request, and the cache is keyed by id only. If the batch function filters by "can the viewer see this?", the answer gets cached under the id and reused. Load the row, then check access in the resolver.

Test the order, not just the count

The usual DataLoader test counts queries. Add one that would have caught Bug 1: request keys in an order the database won't naturally return.

it('returns each post with its own author', async () => { const users = await createUsers(5); // Posts reference users in reverse insertion order for (const u of [...users].reverse()) await createPost({ authorId: u.id }); const res = await execute(`{ posts(first: 5) { authorId author { id } } }`); for (const post of res.data.posts) { expect(post.author.id).toBe(post.authorId); } }); 

Run it against a real Postgres. A mocked db.query returns whatever order you wrote in the mock, which is exactly the assumption that's wrong.

Checklist

  • Batch functions map rows back to keys; they never return rows.
  • Missing rows become null (or []), not a shorter array.
  • Loaders are created per request, never at module level.
  • Access checks run in resolvers, after loading.
  • One test asserts each item gets its own related row, in non-insertion order.

Making Claude Code write it this way

Ask Claude Code to "fix the N+1 with DataLoader" and you'll often get the return rows version, because it looks finished. I wrote this pattern up as a Claude Code skill that finds N+1s, builds per-request loaders with the key mapping above, and adds a query-count test.

Have you hit the wrong-order bug in production? I'd like to hear how long it took to spot.