SysDesignPrep.com
Interactive concepts

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 sequenceSELECT posts: 2.8 msauthor of post 1: 2.3 mscomments on post 1: 2.3 msauthor of post 2: 2.3 mscomments on post 2: 2.3 msauthor of post 3: 2.3 mscomments on post 3: 2.3 msauthor of post 4: 2.3 mscomments on post 4: 2.3 msauthor of post 5: 2.3 mscomments on post 5: 2.3 msauthor of post 6: 2.3 mscomments on post 6: 2.3 msauthor of post 7: 2.3 mscomments on post 7: 2.3 msauthor of post 8: 2.3 mscomments on post 8: 2.3 msauthor of post 9: 2.3 mscomments on post 9: 2.3 msauthor of post 10: 2.3 mscomments on post 10: 2.3 msauthor of post 11: 2.3 mscomments on post 11: 2.3 msauthor of post 12: 2.3 mscomments on post 12: 2.3 msauthor of post 13: 2.3 mscomments on post 13: 2.3 msauthor of post 14: 2.3 mscomments on post 14: 2.3 msauthor of post 15: 2.3 mscomments on post 15: 2.3 msauthor of post 16: 2.3 mscomments on post 16: 2.3 msauthor of post 17: 2.3 mscomments on post 17: 2.3 msauthor of post 18: 2.3 mscomments on post 18: 2.3 msauthor of post 19: 2.3 mscomments on post 19: 2.3 msauthor of post 20: 2.3 mscomments on post 20: 2.3 msauthor of post 21: 2.3 mscomments on post 21: 2.3 msauthor of post 22: 2.3 mscomments on post 22: 2.3 msauthor of post 23: 2.3 mscomments on post 23: 2.3 msauthor of post 24: 2.3 mscomments on post 24: 2.3 msauthor of post 25: 2.3 mscomments on post 25: 2.3 msauthor of post 26: 2.3 mscomments on post 26: 2.3 msauthor of post 27: 2.3 mscomments on post 27: 2.3 msauthor of post 28: 2.3 mscomments on post 28: 2.3 msauthor of post 29: 2.3 mscomments on post 29: 2.3 msauthor of post 30: 2.3 mscomments on post 30: 2.3 msauthor of post 31: 2.3 mscomments on post 31: 2.3 msauthor of post 32: 2.3 mscomments on post 32: 2.3 msauthor of post 33: 2.3 mscomments on post 33: 2.3 msauthor of post 34: 2.3 mscomments on post 34: 2.3 msauthor of post 35: 2.3 mscomments on post 35: 2.3 msauthor of post 36: 2.3 mscomments on post 36: 2.3 msauthor of post 37: 2.3 mscomments on post 37: 2.3 msauthor of post 38: 2.3 mscomments on post 38: 2.3 msauthor of post 39: 2.3 mscomments on post 39: 2.3 msauthor of post 40: 2.3 mscomments on post 40: 2.3 msauthor of post 41: 2.3 mscomments on post 41: 2.3 msauthor of post 42: 2.3 mscomments on post 42: 2.3 msauthor of post 43: 2.3 mscomments on post 43: 2.3 msauthor of post 44: 2.3 mscomments on post 44: 2.3 msauthor of post 45: 2.3 mscomments on post 45: 2.3 msauthor of post 46: 2.3 mscomments on post 46: 2.3 msauthor of post 47: 2.3 mscomments on post 47: 2.3 msauthor of post 48: 2.3 mscomments on post 48: 2.3 msauthor of post 49: 2.3 mscomments on post 49: 2.3 msauthor of post 50: 2.3 mscomments on post 50: 2.3 ms233 ms101101K q/s
N+1, in parallel2N queries over 10 connectionsSELECT posts: 2.8 msauthor of post 1: 2.3 mscomments on post 1: 2.3 msauthor of post 2: 2.3 mscomments on post 2: 2.3 msauthor of post 3: 2.3 mscomments on post 3: 2.3 msauthor of post 4: 2.3 mscomments on post 4: 2.3 msauthor of post 5: 2.3 mscomments on post 5: 2.3 msauthor of post 6: 2.3 mscomments on post 6: 2.3 msauthor of post 7: 2.3 mscomments on post 7: 2.3 msauthor of post 8: 2.3 mscomments on post 8: 2.3 msauthor of post 9: 2.3 mscomments on post 9: 2.3 msauthor of post 10: 2.3 mscomments on post 10: 2.3 msauthor of post 11: 2.3 mscomments on post 11: 2.3 msauthor of post 12: 2.3 mscomments on post 12: 2.3 msauthor of post 13: 2.3 mscomments on post 13: 2.3 msauthor of post 14: 2.3 mscomments on post 14: 2.3 msauthor of post 15: 2.3 mscomments on post 15: 2.3 msauthor of post 16: 2.3 mscomments on post 16: 2.3 msauthor of post 17: 2.3 mscomments on post 17: 2.3 msauthor of post 18: 2.3 mscomments on post 18: 2.3 msauthor of post 19: 2.3 mscomments on post 19: 2.3 msauthor of post 20: 2.3 mscomments on post 20: 2.3 msauthor of post 21: 2.3 mscomments on post 21: 2.3 msauthor of post 22: 2.3 mscomments on post 22: 2.3 msauthor of post 23: 2.3 mscomments on post 23: 2.3 msauthor of post 24: 2.3 mscomments on post 24: 2.3 msauthor of post 25: 2.3 mscomments on post 25: 2.3 msauthor of post 26: 2.3 mscomments on post 26: 2.3 msauthor of post 27: 2.3 mscomments on post 27: 2.3 msauthor of post 28: 2.3 mscomments on post 28: 2.3 msauthor of post 29: 2.3 mscomments on post 29: 2.3 msauthor of post 30: 2.3 mscomments on post 30: 2.3 msauthor of post 31: 2.3 mscomments on post 31: 2.3 msauthor of post 32: 2.3 mscomments on post 32: 2.3 msauthor of post 33: 2.3 mscomments on post 33: 2.3 msauthor of post 34: 2.3 mscomments on post 34: 2.3 msauthor of post 35: 2.3 mscomments on post 35: 2.3 msauthor of post 36: 2.3 mscomments on post 36: 2.3 msauthor of post 37: 2.3 mscomments on post 37: 2.3 msauthor of post 38: 2.3 mscomments on post 38: 2.3 msauthor of post 39: 2.3 mscomments on post 39: 2.3 msauthor of post 40: 2.3 mscomments on post 40: 2.3 msauthor of post 41: 2.3 mscomments on post 41: 2.3 msauthor of post 42: 2.3 mscomments on post 42: 2.3 msauthor of post 43: 2.3 mscomments on post 43: 2.3 msauthor of post 44: 2.3 mscomments on post 44: 2.3 msauthor of post 45: 2.3 mscomments on post 45: 2.3 msauthor of post 46: 2.3 mscomments on post 46: 2.3 msauthor of post 47: 2.3 mscomments on post 47: 2.3 msauthor of post 48: 2.3 mscomments on post 48: 2.3 msauthor of post 49: 2.3 mscomments on post 49: 2.3 msauthor of post 50: 2.3 mscomments on post 50: 2.3 ms26 ms101101K q/s
Batched (DataLoader)1 + 2 queries, WHERE id IN (...)SELECT posts: 2.8 msSELECT users WHERE id IN (50 ids): 3.3 msSELECT post_id, count(*) ... IN (50 ids): 3.9 ms6.7 ms33K q/s
One JOINa single, heavier queryposts JOIN users JOIN comment counts: 5.0 ms5.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.

Read the guide to API design or REST vs gRPC vs GraphQL, see batching and connection pooling, or watch a strong candidate design a news feed.

Questions people ask

What is the N+1 query problem?

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.