A loop that calls a query once per row turns 1 request into 1 + N
round trips. Collect the IDs first and fetch them in one batched
WHERE id IN (...) (or JOIN). Then look each row up from an
in-memory map instead of hitting the database again.
You're here when
- An endpoint gets slower in proportion to how many rows it returns, not how much work each row does
- Query logs or an APM trace show the same shaped query repeated dozens or hundreds of times per request
- An ORM relation is being read inside a
for/.map()over a parent list (order.customer,post.author) - A GraphQL resolver on a list field re-queries per item returned by the parent field
The play
- 1Confirm it's actually N+1: turn on query logging (or open the APM trace) and count how many near-identical queries fire for one request.
- 2Find the loop: a
for/.map()over parent rows that calls.find(),.load(), or a relation getter once per iteration. - 3Pull the loop's per-row lookup out and replace it with one query: collect every parent ID first, then
WHERE id IN (...)or a singleJOIN. - 4Index the batched result by its foreign key (a
Map), and have the loop read from that map instead of querying again. - 5If the N+1 is inside framework-managed resolution (GraphQL field resolvers), swap the manual fix for a batch loader that does this automatically per request.
- 6Re-check the query log: the count should now be flat no matter how many rows come back.
Which path
A straightforward one-to-many
- Collect the foreign keys, one
WHERE id = ANY(...)(or aJOIN), build aMapkeyed by the foreign key. - Works for ORMs too: most ship an eager-load option (
include,with,.load()on a query builder) that does exactly this under the hood.
Nested GraphQL resolvers
- Hand-batching each resolver gets unwieldy fast once fields nest.
- Reach for a per-request
DataLoader: it coalesces every.load(id)call made in the same tick into one batched fetch, and caches within the request.
Gotchas
- 1
One giant IN clause with no chunking
Passing ten thousand IDs into a single
IN/ANYcan hit a parameter limit or just get slow to plan. Chunk into batches of a few hundred to a couple thousand if the ID list is unbounded. - 2
Eager-loading everything by default
Swinging the other way and always including every relation over-fetches on the endpoints that never needed it. Batch the relations a given endpoint actually reads, not every relation the model has.
Confirm you're clear
- 1The query log shows a fixed, small number of queries for the endpoint regardless of how many rows it returns.
- 2Latency scales roughly linearly with result size, not with an extra round trip per row.
- 3Doubling the row count in a test fixture doesn't double the query count.