The N+1 query problem, batching and DataLoader: an interactive query waterfall
The N+1 query problem is one query to load a list, then one more query for every item in it. It is easy to write by accident, an ORM relation touched inside a loop or a GraphQL resolver per field, and it is invisible with ten rows on a laptop next to the database. In production, each of those queries pays a network round trip, and the page time grows with the number of items times the distance to the database.
The playground loads a feed page of posts with each post’s author and comment count. Change the page size and how far away the database is.
Database round trip
A feed page shows 50 posts, each with its author and comment count, and each query costs a 2 ms round trip to the database plus its own work. Loading the authors and counts post by post, the N+1 pattern an ORM produces by default, sends 101 queries one after another: 233 ms. Sending them in parallel over 10 connections cuts that to 26 ms but still costs 101 queries and ties up the whole pool for one page. Batching the ids into two IN queries, as DataLoader does, takes 3 queries and 6.7 ms; one JOIN takes 5.0 ms. At 1,000 pages a second that is 101K queries a second against 3K.
ApproachQueries over time, same scalePage timeQueriesAt 1K pages/s
N+1, one by one1 + 2N queries in sequence233 ms101101K q/s
N+1, in parallel2N queries over 10 connections26 ms101101K q/s
Batched (DataLoader)1 + 2 queries, WHERE id IN (...)6.7 ms33K q/s
One JOINa single, heavier query5.0 ms11K q/s
the query for the postsfollow-up queries, one bar each
What the waterfalls show
One by one, 50 posts mean 101 queries and 233 ms with a 2 ms round trip, almost all of it waiting on the network. Against a database in another region, 20 ms away, the same page takes over 2 seconds.
In parallel it drops to 26 ms, but it is still 101 queries per page, 101,000 a second at 1,000 pages a second, and one page occupies all 10 connections of the pool while it loads.
Batched, the authors and the counts are each one query with all the ids: 3 queries and about 7 ms whatever the page size, because the round trips no longer multiply.
One JOIN is a single round trip, 5 ms here. It suits one-to-one relations; joining one-to-many relations repeats the parent rows and can return far more data than separate batched queries.
The fix in code
// the N+1: one query for the list, then one per item, hidden inside a loop or a resolver
const posts = await db.query('SELECT * FROM posts ORDER BY created_at DESC LIMIT 50');
for (const p of posts) {
p.author = await db.query('SELECT * FROM users WHERE id = $1', [p.author_id]); // 50 queries
}
// batched: collect the keys, one query per kind of thing (what DataLoader automates)
const ids = [...new Set(posts.map((p) => p.author_id))];
const users = await db.query('SELECT * FROM users WHERE id = ANY($1)', [ids]); // 1 query
const byId = new Map(users.map((u) => [u.id, u]));
posts.forEach((p) => (p.author = byId.get(p.author_id)));
// ORMs: ask for eager loading
Post.findAll({ include: [User] }) // Sequelize
Post.objects.select_related('author') # Django (JOIN)
Post.objects.prefetch_related('comments') # Django (one IN query per relation)
To catch it, log the number of queries per request in development and fail tests above a threshold, and look for many identical query shapes in a trace of one request. In GraphQL servers, a DataLoader per request batches and caches the lookups that resolvers make for each field.
In a system design interview
When an API returns lists with related data, show that it loads them in bulk: “The feed endpoint fetches the page of post ids, then loads authors and counts with one batched query each, or from a cache with a multi-get, so a page is a handful of round trips regardless of its size. For GraphQL we use a DataLoader per request.” Expect the follow-ups: how batching interacts with caching, how to bound the size of IN lists, and when to denormalize the author name into the post instead.
Running one query to fetch a list of N items and then one more query for each item to fetch related data, N + 1 queries in total. Each query pays a round trip to the database, so the time grows with the number of items and the network latency.
How do you fix N+1 queries?
Load related data in bulk: collect the ids and fetch them with one IN or ANY query per relation, join in a single query where it fits, or use the ORM’s eager loading such as include, select_related or prefetch_related.
What is DataLoader?
A small library, originally from Facebook for GraphQL, that collects the individual load calls made during one tick of the event loop, sends them as one batched request, and caches results for the rest of the request. It turns per-field resolver lookups into a few batched queries.
Is a JOIN always better than separate queries?
Not always. A JOIN is one round trip and suits one-to-one or small one-to-many relations, but joining several one-to-many relations multiplies the rows returned. Separate batched queries per relation are often simpler and transfer less data.
How do you detect N+1 queries?
Count queries per request in logs or APM traces and look for many queries with the same shape, add tests that assert a maximum number of queries for an endpoint, and use ORM tools that warn about lazy loading in loops.
Why do N+1 queries go unnoticed in development?
Locally the database is on the same machine, round trips take microseconds and lists are short. In production the database is a network hop away and lists are long, so the same code becomes hundreds of milliseconds slower.
Does running the queries in parallel fix N+1?
It reduces latency but not the number of queries: the database still serves N + 1 queries per request, and one request can occupy many pool connections. Batching reduces both the round trips and the load.