N+1 Queries & ORMs
The most common performance bug, and how to see it: why one page quietly makes 51 database round trips, how ORMs hide them, and the JOIN, batch, eager-loading and DataLoader fixes, measured on a real Postgres.
The 30-second version
Here's the classic case. A blog's home page shows the 50 newest posts, each with its author's name. The code does this:
- One query: "give me the 50 newest posts". Each post knows its author's id, but not the name.
- Then, for each of the 50 posts: "give me the author with id 920", "…id 839", "…id 758", … 50 more queries.
That's 51 queries for one page. Each query has to travel to the Database and back. That's a round trip (Round-trip time), roughly 1 ms inside a data center (approximate; it varies with your network). The trips happen one after another, so the page waits about 51 × 1 ms ≈ 50 ms before it can even start rendering. The same data could arrive in 2 queries (all posts, then all their authors at once) or 1 (both together). That's 2 ms instead of 50.
It's called "the most common performance bug" for a reason: the code looks perfectly innocent (a for loop and what looks like reading a field), each query is individually fast, and it works fine on your laptop with 5 posts. It only hurts when N grows, or the database is a few milliseconds away instead of on the same machine.
Play with it. Push N to 200 and the round trip to 5 ms, then compare the strategies:
What you'll learn
- Why it happens: how an ORM hides queries behind innocent-looking code (§03).
- How to see it: query logs, query counters and tracing "staircases" (§04).
- How to fix it: JOINs, batched
INqueries, ORM eager loading, nested JSON from Postgres, and DataLoader (§05–§08). - What doesn't fix it: running the queries in parallel (§09), and the related traps nearby (§10).
- How to keep it fixed: tests that count queries (§11), and the staff-level view: when N+1 is fine, and the same bug between microservices (§12).
Primer: tables, SQL and round trips
Tables, rows and ids
A relational Database (one that stores data in tables that point at each other) such as PostgreSQL stores data in tables, which work like spreadsheets: one row per thing, one column per field. Our blog has two:
authors
| id | name |
|---|---|
| 920 | Ada |
| 839 | Lin |
posts
| id | title | author_id | created_at |
|---|---|---|---|
| 1 | Hello SQL | 920 | today 10:00 |
| 2 | Why N+1 | 839 | today 09:59 |
| 3 | Indexes 101 | 920 | today 09:58 |
Each row has an id that no other row in that table shares: its Primary key. A post doesn't store its author's name. It stores the author's id in author_id, a Foreign key that points at a row in authors. That's like a library card that says "see shelf 920" instead of copying the whole book. Storing the name once means renaming an author is one change, not thousands. But it also means showing a post with its author's name needs data from two tables, and that's where N+1 comes from.
SQL in five lines
SQL is the language you use to ask. These are the only forms this page needs:
SELECT id, title, author_id FROM posts ORDER BY created_at DESC LIMIT 50;
SELECT name FROM authors WHERE id = 920;
SELECT id, name FROM authors WHERE id IN (920, 839, 758);
SELECT p.title, a.name
FROM posts p
JOIN authors a ON a.id = p.author_id;The result of a SELECT is always a flat table of rows, even after a JOIN. Keep that in mind; it matters in §05.
Every query is a round trip
The database is a separate program, usually on another machine. Your code sends the SQL text over a network connection, the database does the work, and the rows come back. Until they do, your code waits.
Here's the number that explains this whole topic. On our test Postgres, finding one author by id took the database 0.03–0.06 ms. A round trip inside a data center costs around 0.5–1 ms. So for a simple lookup, more than 90% of the time is spent travelling, not working. Ten lookups cost ten trips. One query that asks for ten rows costs one trip.
ORMs, lazy loading and the innocent loop
What an ORM is
Writing SQL by hand and copying every column into a struct or object gets tedious. An ORM (object-relational mapper) does that for you. You describe your tables as classes or structs, including how they relate ("a post belongs to an author, an author has many posts"), and the ORM writes the SQL. Popular ones: Prisma, TypeORM and Sequelize in JavaScript; GORM and ent in Go (where many teams use plain SQL instead, via database/sql or code generators like sqlc).
That's genuinely useful. But it moves the SQL out of sight, and SQL you can't see is SQL you don't count.
The innocent loop
Here's the blog page with two real ORMs. Neither line inside the loop looks like database access, but each one is a query:
type Author struct {
ID int64
Name string
}
type Post struct {
ID int64
Title string
AuthorID int64
Author Author // filled in only if you ask
}
var posts []Post
// (errors ignored for brevity; check .Error in real code)
db.Order("created_at DESC").Limit(50).Find(&posts) // 1 query
for i := range posts {
db.First(&posts[i].Author, posts[i].AuthorID) // ← 1 query per post
}With GORM's logger switched on, our test database printed exactly what you'd fear (trimmed to 3 posts to save space):
[1.592ms] [rows:3] SELECT * FROM "posts" ORDER BY created_at DESC LIMIT 3
[0.618ms] [rows:1] SELECT * FROM "authors" WHERE "authors"."id" = 920 ORDER BY "authors"."id" LIMIT 1
[0.162ms] [rows:1] SELECT * FROM "authors" WHERE "authors"."id" = 839 ORDER BY "authors"."id" LIMIT 1
[0.142ms] [rows:1] SELECT * FROM "authors" WHERE "authors"."id" = 758 ORDER BY "authors"."id" LIMIT 1With 50 posts it's 51 lines. Notice the timings: every query is well under a millisecond, so none of them looks slow. The problem is how many there are.
Lazy loading: the query that looks like a field
In those two examples you can at least see a method call in the loop. Many ORMs go further with Lazy loading: post.author itself runs the query, the first time you read it. Rails (ActiveRecord), Django, Hibernate and TypeORM's "lazy relations" all work this way (details vary by library and version). It isn't magic. Here's the whole trick in a few lines of plain JavaScript:
class Post {
constructor(row, db) {
Object.assign(this, row); // copy id, title, author_id onto the object
this.db = db;
}
get author() { // a getter: runs code when you READ post.author
return this.db
.query("SELECT * FROM authors WHERE id = $1", [this.author_id])
.then((res) => res.rows[0]); // ← a query hiding behind a "field"
}
}
for (const post of posts) {
const author = await post.author; // looks like a field. It's a query.
}Step through it and watch the SQL log on the right:
1const posts = await Post.findAll({ limit: 50 });23for (const post of posts) {4 const author = await post.author; // a field?5 html += `<li>${post.title} by ${author.name}</li>`;6}
author_id — but not the author.Go has the same bug, just without the magic
Go ORMs like GORM don't lazy-load: a relation stays empty unless you ask for it. But Go code hides queries in a different place: helper functions. A function like authorName(ctx, id) that runs a SELECT is perfectly reasonable on its own. Call it from a loop, or from a function that's called from a loop three files away, and you've written an N+1 without ever seeing the loop and the query together.
Seeing it: logs, counters and staircases
1. Ask the database to log every statement
Postgres can write every statement it receives to its log file. The setting is log_statement = 'all'. Turn it on for a local or staging database (in production it's far too noisy), load the page once, and read the log. To switch it on, run ALTER DATABASE blog SET log_statement = 'all'; as a superuser (new connections pick it up), or start a Docker Postgres with postgres -c log_statement=all. This is real output from our test database while a plain database/sql version of the §03 loop (SELECT name FROM authors WHERE id = $1 per post) loaded 4 posts:
LOG: execute stmtcache_d2bf…: SELECT id, title, author_id FROM posts ORDER BY created_at DESC LIMIT $1
DETAIL: parameters: $1 = '4'
LOG: execute stmtcache_14e3…: SELECT name FROM authors WHERE id = $1
DETAIL: parameters: $1 = '920'
LOG: execute stmtcache_14e3…: SELECT name FROM authors WHERE id = $1
DETAIL: parameters: $1 = '839'
LOG: execute stmtcache_14e3…: SELECT name FROM authors WHERE id = $1
DETAIL: parameters: $1 = '758'
LOG: execute stmtcache_14e3…: SELECT name FROM authors WHERE id = $1
DETAIL: parameters: $1 = '677'That's the fingerprint: the same statement, over and over, with only the parameter changing. (The stmtcache_… name is the Go driver, pgx, reusing a Prepared statement: the database parsed the SQL once and now gets only the new $1 value each time. That makes each query a bit cheaper, but it's still one round trip each.)
Your ORM can print the same thing from your side: GORM with Logger: logger.Default.LogMode(logger.Info) (output shown in §03), Prisma with new PrismaClient({ log: ["query"] }). For a whole database there's also pg_stat_statements, a Postgres extension that keeps a running count of calls per distinct query. An authors-by-id query with a call count 50× higher than the posts query that feeds it is a strong hint.
2. Count queries per request
Even simpler: count. Wrap whatever sends queries so it increments a counter, and log the total at the end of each request, e.g. GET /posts → 51 queries, 54 ms. A page that should need 2 queries and runs 51 stands out at once. Better still, put the count in a test so it can never creep back (§11 shows the code in Go and JS).
3. Look at a trace: the staircase
In production you usually see N+1 through an APM or tracing tool. It records each request as a set of timed spans, one per database query, and draws them as a waterfall. N+1 has an unmistakable shape: a staircase of short, identical bars, each starting when the previous one ends.
Switch between the modes. The staircase is the loop: N queries, each waiting for the previous one. The batch and JOIN versions collapse the stairs into one or two bars. (The parallel mode looks better than the loop, but it's not a fix; §09 explains why.)
Fix 1: let the database JOIN
The fix
Ask for posts and authors together. The database pairs them up and sends back finished rows:
rows, err := db.QueryContext(ctx, `
SELECT p.id, p.title, a.name
FROM posts p
JOIN authors a ON a.id = p.author_id
ORDER BY p.created_at DESC
LIMIT 50`)
if err != nil {
return nil, err
}
defer rows.Close()
var posts []Post // Post here has an AuthorName string field
for rows.Next() { // one row = one post WITH its author's name
var p Post
if err := rows.Scan(&p.ID, &p.Title, &p.AuthorName); err != nil {
return nil, err
}
posts = append(posts, p)
}
return posts, rows.Err()What the database does with it
Prefix any query with EXPLAIN ANALYZE and Postgres runs it and shows its plan: the steps it took, how many rows each produced, and the time. (Reading plans properly is taught in Indexes; here we only need one line.) This is the plan for the query above, from our test database with 100,000 posts and 1,000 authors:
Limit (actual time=0.028..0.122 rows=50 loops=1)
-> Nested Loop (actual time=0.027..0.118 rows=50 loops=1)
-> Index Scan using posts_created_at_idx on posts p (… rows=50 loops=1)
-> Memoize (actual time=0.002..0.002 rows=1 loops=50)
-> Index Scan using authors_pkey on authors a (… rows=1 loops=50)
Index Cond: (id = p.author_id)
Execution Time: 0.230 msRead it from the inside out. Index Scan means "find rows using an index"; Nested Loop means "for each post, look up its author"; Memoize means "remember authors already found, in case they come up again". Now look at loops=50 on the last line. The database still looks up 50 authors one by one. It's the very same loop as the N+1. The difference is that each lookup happens inside the database, in about 0.002 ms, instead of across the network at ~1 ms. The whole thing took 0.23 ms.
Measured from Go through a connection with a 1 ms round trip (median of 30 runs):
| Strategy | Queries | Time |
|---|---|---|
| N+1 loop | 51 | 55 ms |
| JOIN | 1 | 1.5 ms |
The catch: one row per child
A post has exactly one author, so a posts-JOIN-authors result has one row per post. That's a belongs-to relation, and it's where JOIN shines.
Real blogs have more. Our test database has three more tables:
comments
| id | post_id | body |
|---|---|---|
| 11 | 1 | Nice! |
| 12 | 1 | Thanks |
| 13 | 2 | +1 |
tags
| id | name |
|---|---|
| 1 | go |
| 2 | sql |
| 3 | perf |
post_tags
| post_id | tag_id |
|---|---|
| 1 | 1 |
| 1 | 2 |
| 2 | 3 |
A comment belongs to one post (comments.post_id), so a post has many comments. Tags are different: a post can have many tags and a tag can be on many posts (many-to-many). One tag_id column on posts can't hold that, so a small Join table stores one row per (post, tag) pair.
Now JOIN a has-many relation, posts with their comments. The result can only be flat, so you get one row per comment, and the post's title and body are repeated on every one of them.
Add a second has-many (tags) and it gets much worse: the database returns every combination of comment and tag. This is the Cartesian explosion. Here's post 1 from our test data, which has 4 comments and 3 tags:
SELECT p.id, p.title, c.id AS comment_id, t.name AS tag
FROM (SELECT * FROM posts ORDER BY created_at DESC LIMIT 1) p
LEFT JOIN comments c ON c.post_id = p.id
LEFT JOIN post_tags pt ON pt.post_id = p.id
LEFT JOIN tags t ON t.id = pt.tag_id; id | title | comment_id | tag
----+--------+------------+-------
1 | Post 1 | 120506 | tag16
1 | Post 1 | 120506 | tag2
1 | Post 1 | 120506 | tag9
1 | Post 1 | 220506 | tag16
1 | Post 1 | 220506 | tag2
… 7 more rows: every comment repeated once per tag …For the full 50-post page, the database returned 452 rows instead of 50. Each post's 2.4 KB body was copied into every row: 1.06 MB of bodies instead of about 120 KB. Your code then has to walk the rows and throw the duplicates away. Try it on one post, including what an inner JOIN does when a count drops to 0:
Fix 2: batch with IN / ANY, stitch in memory
This is the most useful fix to know by heart. It works for every kind of relation, even across two different databases, and it's exactly what most ORMs do under the hood when you ask them to eager-load (§07).
The recipe: collect, fetch, index, stitch
First watch it on 6 posts, then read the full code:
1const ids = [...new Set(posts.map((p) => p.author_id))];23const { rows: authors } = await pool.query(4 "SELECT id, name FROM authors WHERE id = ANY($1)", [ids]);56const byId = new Map(authors.map((a) => [a.id, a]));78for (const post of posts) {9 post.authorName = byId.get(post.author_id)?.name;10}
func attachAuthors(ctx context.Context, db *sql.DB, posts []Post) error {
// 1. Collect each author id once.
seen := make(map[int64]bool)
var ids []int64
for _, p := range posts {
if !seen[p.AuthorID] {
seen[p.AuthorID] = true
ids = append(ids, p.AuthorID)
}
}
// 2. One query for all of them.
rows, err := db.QueryContext(ctx,
`SELECT id, name FROM authors WHERE id = ANY($1)`, ids)
if err != nil {
return err
}
defer rows.Close()
// 3. Index the answers by id…
names := make(map[int64]string, len(ids))
for rows.Next() {
var id int64
var name string
if err := rows.Scan(&id, &name); err != nil {
return err
}
names[id] = name
}
if err := rows.Err(); err != nil {
return err
}
// 4. …and stitch them onto the posts, in memory.
for i := range posts {
posts[i].AuthorName = names[posts[i].AuthorID]
}
return nil
}posts here comes from the plain list query (SELECT id, title, author_id FROM posts …), so the whole page is 2 queries. Our test Postgres used the primary-key index for the batch, exactly as it would for one id:
Index Scan using authors_pkey on authors (actual time=0.039..0.045 rows=10 loops=1)
Index Cond: (id = ANY ('{1,2,3,4,5,6,7,8,9,10}'::bigint[]))
Execution Time: 0.073 msTen authors in 0.073 ms of database time, versus 0.059 ms for one. The batch costs almost nothing extra; the round trips you saved were the whole cost. Measured over a 1 ms connection, the 50-post page went from 55 ms (51 queries) to 2.7 ms (2 queries).
Has-many: group instead of index
For comments, each post has a list. Same recipe, but the map goes from post id to a slice/array:
rows, err := db.QueryContext(ctx,
`SELECT post_id, id, body FROM comments WHERE post_id = ANY($1) ORDER BY id`, postIDs)
// … if err != nil, defer rows.Close() as before …
byPost := make(map[int64][]Comment) // post id → its comments
for rows.Next() {
var postID int64
var c Comment
if err := rows.Scan(&postID, &c.ID, &c.Body); err != nil {
return err
}
byPost[postID] = append(byPost[postID], c) // append works on a missing (nil) entry
}
// … check rows.Err() …
for i := range posts {
posts[i].Comments = byPost[posts[i].ID]
}Do the same for tags and the full page (posts + authors + comments + tags) is 4 queries, no duplicated rows, whatever N is. On our test setup at 1 ms per round trip, the N+1 version of that page ran 151 queries in 176 ms. The batched version took 5.2 ms.
Limits worth knowing
- Very long lists.
IN ($1, $2, …)with one placeholder per id hits protocol limits: Postgres allows at most 65,535 parameters per statement. One array parameter withANY($1)avoids that, but a list of 100,000 ids is still a huge query. If your N can be that big, process the ids in chunks of a few thousand, or rethink the page (nobody reads 100,000 posts). - Order isn't guaranteed.
WHERE id = ANY($1)returns rows in whatever order the database likes, not the order of your ids. That's why we stitch through a map instead of assuming row 1 belongs to post 1. - Missing rows. If an author was deleted, the map just has no entry. Decide what the page shows (
"unknown author"?) instead of crashing onundefined.
Fix 3: eager loading, and nested JSON from Postgres
Eager loading: tell the ORM up front
With Eager loading, you list the relations you'll need when you fetch the list, and the ORM loads them for all rows at once. These are the real statements our test database received (3 posts, so the lists are short):
// Preload: one extra query per relation
db.Preload("Author").Order("created_at DESC").Limit(3).Find(&posts)
// SELECT * FROM "posts" ORDER BY created_at DESC LIMIT 3
// SELECT * FROM "authors" WHERE "authors"."id" IN (920,839,758)
// Joins: a single LEFT JOIN (belongs-to / has-one only)
db.Joins("Author").Order("posts.created_at DESC").Limit(3).Find(&posts)
// SELECT "posts"."id", …, "Author"."name" AS "Author__name"
// FROM "posts" LEFT JOIN "authors" "Author" ON "posts"."author_id" = "Author"."id"
// ORDER BY posts.created_at DESC LIMIT 3(Prisma always adds OFFSET to its queries, here with the value 0, which skips nothing; ignore it.) So Preload and include are §06's recipe, automated: one query for the list, then one IN (…) query per relation, stitched together in memory. That's 3 queries whatever N is, and because each table comes back separately there's no Cartesian explosion. GORM's Joins is the §05 JOIN instead; GORM only allows it for to-one relations, where JOIN is the right tool.
Nested JSON straight from Postgres
The page doesn't want flat rows at all. It wants posts, each holding a list of comments and a list of tags: a tree. Postgres can build that tree itself. json_agg collects rows into one JSON array, and a small SELECT inside the main one (a Correlated subquery) does it once per post:
SELECT p.id, p.title, a.name AS author,
(SELECT coalesce(json_agg(json_build_object('id', c.id, 'body', c.body) ORDER BY c.id), '[]')
FROM comments c WHERE c.post_id = p.id) AS comments,
(SELECT coalesce(json_agg(t.name ORDER BY t.name), '[]')
FROM post_tags pt JOIN tags t ON t.id = pt.tag_id
WHERE pt.post_id = p.id) AS tags
FROM posts p
JOIN authors a ON a.id = p.author_id
ORDER BY p.created_at DESC
LIMIT 50;One row per post, children already nested. This is real output (two posts shown, comments trimmed to fit):
id | title | author | comments | tags
----+--------+------------+-------------------------------------------------------+-------------------------
1 | Post 1 | Author 920 | [{"id" : 120506, "body" : "Comment 100000"}, … 3 more] | ["tag16", "tag2", "tag9"]
2 | Post 2 | Author 839 | [{"id" : 115875, "body" : "Comment 95369"}, … 3 more] | ["tag10", "tag3"]Reading it back is easy in both languages. node-postgres turns json columns into JS objects automatically. In Go, wrap the whole query in one more json_agg so the page arrives as a single JSON array, then decode it into structs:
type CommentView struct {
ID int64 `json:"id"`
Body string `json:"body"`
}
type PostView struct {
ID int64 `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
Comments []CommentView `json:"comments"`
Tags []string `json:"tags"`
}
// pageQuery = `SELECT coalesce(json_agg(x ORDER BY x.created_at DESC), '[]')
// FROM ( …query above, plus p.created_at… ) x`
// (the ORDER BY inside json_agg keeps the posts in page order)
var raw []byte // the whole page, as one JSON array
if err := db.QueryRowContext(ctx, pageQuery).Scan(&raw); err != nil {
return nil, err
}
var page []PostView
if err := json.Unmarshal(raw, &page); err != nil {
return nil, err
}Which shape when?
Flip through the four result shapes for the same tiny data set:
SELECT p.id, p.title, a.name, c.id, c.body FROM posts p JOIN authors a ON a.id = p.author_id LEFT JOIN comments c ON c.post_id = p.id
| post | title | author | c.id | comment |
|---|---|---|---|---|
| 1 | Hello SQL | Ada | 11 | Nice! |
| 1 | Hello SQL | Ada | 12 | Thanks |
| 2 | Why N+1 | Lin | 13 | +1 |
Here are all strategies on our test database, for the full page (50 posts with author, comments and tags), measured from Go over a connection with a 1 ms round trip (median of 20 runs):
| Strategy | Queries | Bytes received | Time |
|---|---|---|---|
| N+1 (one query per post per relation) | 151 | 126 KB | 176 ms |
| One big JOIN | 1 | 1,108 KB | 4.6 ms |
Batch with ANY (§06 / ORM eager loading) | 4 | 129 KB | 5.2 ms |
json_agg | 1 | 134 KB | 4.2 ms |
Over a 1 ms round trip, the three fixes are all within a millisecond of each other, and all about 35–40× faster than the N+1. On the same machine (almost no round trip), the batch version was the fastest (0.9 ms vs 3.9 ms for json_agg), because building JSON is real work for the database.
Fix 4: DataLoader, batching you don't have to plan
load(id) made at about the same moment joins one batch, and one query answers them all.Where §06 doesn't fit: GraphQL resolvers
The batch recipe needs one place that sees the whole list. Sometimes there isn't one. The classic case is GraphQL. The client sends a query describing the shape it wants:
{
posts(first: 50) {
title
author { name }
}
}The server answers it by calling a resolver function for each field of each object. Query.posts runs once and returns 50 posts. Then Post.author runs once per post, and each call only knows about its own post:
const resolvers = {
Query: {
posts: async () => (await db.query("SELECT * FROM posts ORDER BY created_at DESC LIMIT 50")).rows,
},
Post: {
// called 50 times, once per post; each call can only see its own post
author: async (post) =>
(await db.query("SELECT * FROM authors WHERE id = $1", [post.author_id])).rows[0],
},
};That's an N+1 built into the architecture, and nested fields make it worse (each post's comments, each comment's author…). The same happens in any code split into small independent functions: React Server Components, serializers, "fetch what you need" helpers.
The idea: collect calls made in the same tick
DataLoader (the npm package dataloader, originally from Facebook's GraphQL team) works like a waiter taking orders for a table. Each person orders separately, but the waiter doesn't run to the kitchen after every order. They collect everyone's order and go once.
You give it one batch function: "given these ids, return their values". Resolvers call loader.load(id) instead of querying:
import DataLoader from "dataloader";
function makeLoaders(db) {
return {
author: new DataLoader(async (ids) => {
const { rows } = await db.query(
"SELECT id, name FROM authors WHERE id = ANY($1)", [ids]);
const byId = new Map(rows.map((a) => [a.id, a]));
// MUST return one result per id, in the same order as `ids`
return ids.map((id) => byId.get(id) ?? new Error(`no author ${id}`));
}),
};
}
// The resolver no longer queries; it asks the loader:
const resolvers = {
Post: { author: (post, _args, ctx) => ctx.loaders.author.load(post.author_id) },
};
// …and every request gets FRESH loaders, e.g. in your GraphQL context function:
// context: () => ({ loaders: makeLoaders(db) })How does it know which calls belong together? JavaScript runs one piece of code at a time. When load is called, the loader adds the id to the current batch and schedules the dispatch to run right after the currently running code and its queued promise callbacks have finished (built on the Microtask queue, see the event loop). The GraphQL server calls all 50 Post.author resolvers in one go, so all 50 load calls land in the same batch. Step through it:
1const authorLoader = new DataLoader(async (ids) => {2 const { rows } = await db.query(3 "SELECT id, name FROM authors WHERE id = ANY($1)", [ids]);4 const byId = new Map(rows.map((a) => [a.id, a]));5 return ids.map((id) => byId.get(id) ?? new Error(id));6});78// three resolvers run during the same tick:9const a = authorLoader.load("920"); // post 110const b = authorLoader.load("839"); // post 211const c = authorLoader.load("920"); // post 312await Promise.all([a, b, c]);1314await authorLoader.load("839"); // later
We ran this against our test database: 6 resolvers asking for authors produced one query, WHERE id = ANY('{920,839,758,677,596,515}'). Asking again for an id already loaded produced no query at all.
The three rules of DataLoader
Same length, same order
WHERE id = ANY returns rows in any order, which is why we go through a Map. For a missing id, put an Error in its slot: that one load rejects, the others still succeed.One loader per request
Don't await inside a loop
for (const id of ids) await loader.load(id) waits for each result before asking for the next, so every batch holds one id: we measured 6 batches for 6 ids. Start all the loads first: await Promise.all(ids.map((id) => loader.load(id))).Go: a time window instead of a tick
Go has no "tick". Goroutines run truly at the same time, so there's no moment when "everything currently running has finished". A Go loader uses a short time window instead: the first Load opens a batch and starts a timer (say 1 ms), every Load that arrives before it fires joins, then one query runs.
Load and waits (striped). The first call starts a 1 ms timer; every call that arrives before it fires joins the same batch. Then one query runs and all waiting goroutines wake up together.Here's a minimal version, complete except for fetchAuthors, which is §06's WHERE id = ANY($1) query returning a map[int64]Author. (We ran it with Go's race detector: 50 goroutines, 1 query. When goroutines start slowly, a batch can split in two because some calls arrive after the window closed. That's the trade-off of a window.)
// AuthorLoader collects Load calls for a short window, then runs ONE query.
type AuthorLoader struct {
db *sql.DB
wait time.Duration // how long a batch collects ids, e.g. time.Millisecond
mu sync.Mutex
batch *authorBatch // the batch currently collecting; nil = none
}
type authorBatch struct {
ids []int64
done chan struct{} // closed when res/err are ready
res map[int64]Author
err error
}
func (l *AuthorLoader) Load(ctx context.Context, id int64) (Author, error) {
l.mu.Lock()
b := l.batch
if b == nil { // first caller opens a new batch and starts its timer
b = &authorBatch{done: make(chan struct{})}
l.batch = b
time.AfterFunc(l.wait, func() { l.fire(b) })
}
b.ids = append(b.ids, id)
l.mu.Unlock()
select { // wait for the batch's single query (or give up)
case <-b.done:
case <-ctx.Done():
return Author{}, ctx.Err()
}
if b.err != nil {
return Author{}, b.err
}
a, ok := b.res[id]
if !ok {
return Author{}, fmt.Errorf("author %d not found", id)
}
return a, nil
}
func (l *AuthorLoader) fire(b *authorBatch) {
l.mu.Lock()
l.batch = nil // later Loads start a fresh batch; b.ids is now final
l.mu.Unlock()
b.res, b.err = fetchAuthors(context.Background(), l.db, b.ids) // WHERE id = ANY($1)
close(b.done) // wakes every waiting Load at once
}This toy has no cache and no maximum batch size; production libraries add both. In Go, look at graph-gophers/dataloader (v7 uses generics) or vikstrous/dataloadgen, which the gqlgen GraphQL docs point to. Whatever you use, the window is a trade-off: every request waits up to that long before its first batch runs, in exchange for far fewer queries.
Running the queries in parallel is not a fix
The tempting "fix"
Once you know the loop waits for each query in turn, the obvious idea is to stop waiting:
var wg sync.WaitGroup
for i := range posts {
wg.Add(1)
go func() {
defer wg.Done()
// each goroutine fills in a different post, so they don't clash
err := db.QueryRowContext(ctx, `SELECT name FROM authors WHERE id = $1`,
posts[i].AuthorID).Scan(&posts[i].AuthorName)
_ = err // (real code must collect errors, e.g. with errgroup)
}()
}
wg.Wait() // still 50 queries, just not one after anotherAnd it does look faster. Measured from Node with a Connection pool of 10 connections and a 1 ms round trip (median of 20 runs):
| Strategy | Queries | Time for one page |
|---|---|---|
Loop, one await at a time | 51 | 71 ms |
Promise.all | 51 | 12 ms |
Batch with ANY (§06) | 2 | 3 ms |
(These are Node numbers; node-postgres adds a little overhead per query, which is why its loop is slower than the 55 ms Go loop in §05.)
Watch the timeline. With only 4 connections, the queries that can't get one wait in line (striped):
Why it falls over under load
That 12 ms is what you get with one user. Here's the step people miss: running queries in parallel doesn't reduce how long connections are busy, it just squeezes the same busy time into a shorter period.
Do the arithmetic for a pool of 10 connections. Each query keeps a connection busy for about 1 ms, round trip included.
- N+1, loop or parallel: 51 queries × 1 ms = 51 connection-milliseconds per page. The pool has 10 connections × 1,000 ms = 10,000 connection-ms per second, so it can serve at most about 200 pages per second. Beyond that, requests queue for a connection and everyone gets slower.
- Batched: 2 queries × 1 ms = 2 connection-ms per page, or about 5,000 pages per second from the same pool.
Parallelism only changes who waits. When traffic is low, one page uses 10 connections and finishes sooner. When traffic is high, each page's 50 queries compete with every other page's queries for the same 10 connections, and the pool's waiting line grows. That's exactly the "start everything at once" failure from Worker Pools. Push the traffic past 200 pages per second and watch:
Pitfalls gallery: N+1's relatives
N+1 on writes: inserting in a loop
Saving 1,000 rows with 1,000 INSERT statements is the same bug in the other direction. It's even worse, because outside a Database transaction each insert is its own commit, and every commit waits for the database to fsync its log to disk (that's what makes a commit durable).
Measured on our test Postgres, inserting 1,000 rows:
| Strategy | Same machine | 1 ms round trip |
|---|---|---|
1,000 INSERTs, each its own commit | 301 ms | 1,475 ms |
1,000 INSERTs inside one transaction | 68 ms | 1,190 ms |
One INSERT … SELECT FROM unnest(…) | 2.4 ms | 5.4 ms |
// postIDs []int64 and viewers []string: one entry per row
_, err := db.ExecContext(ctx, `
INSERT INTO views (post_id, viewer)
SELECT * FROM unnest($1::bigint[], $2::text[])`,
postIDs, viewers) // ← two arrays, one round tripunnest(a, b) turns two arrays into rows: element 1 of each becomes row 1, and so on. $1::bigint[] tells Postgres the parameter is an array of bigints. ORMs have the same thing built in: GORM's db.Create(&views) with a slice sent one INSERT … VALUES (1,'a'),(2,'b'),(3,'c'), and Prisma's createMany sent one INSERT … VALUES ($1,$2), ($3,$4). For very large loads (hundreds of thousands of rows), Postgres's COPY is faster still (pgx has CopyFrom; in Node, the pg-copy-streams package).
Lazy loads in templates and serializers
The handler loads posts correctly, then hands them to an HTML template or a JSON Serializer, which reads post.author.name for each post. With a lazy-loading ORM, that line runs the query, in the template, far away from the code that loaded the data. Nobody reviewing the handler sees it.
Fix: load everything the response needs in the handler (eager loading, §07), and give the template plain data. Some frameworks can make accidental lazy loads fail loudly instead: Rails has strict_loading, and Django has third-party packages that raise or warn on them.
Counting per row
"Show each post with its number of comments" tempts you to call count() per post, which is an N+1 of aggregates (quiz Q5). One grouped query answers it for the whole page:
SELECT post_id, count(*) AS comments
FROM comments
WHERE post_id = ANY($1)
GROUP BY post_id; -- one row per post: (post_id, how many comments)GROUP BY post_id makes one result row per distinct post_id, and count(*) counts the rows in each group. Posts with no comments simply don't appear, so treat a missing entry in your map as 0.
SELECT * when the page shows the title
Both ORMs above select every column by default (SELECT * FROM "posts", or Prisma's full column list). Our posts have a 2.4 KB body; a list page that shows only titles still downloads about 120 KB of bodies for 50 posts. That's Over-fetching. It can also stop the database from answering from the index alone, without reading the table rows (an index-only scan; see Indexes).
Ask for what you show: GORM db.Select("id", "title", "author_id"), Prisma select: { id: true, title: true }.
Deep pages with OFFSET
LIMIT 50 OFFSET 90000 means "find 90,050 rows in order, throw away the first 90,000". The database can't skip them; it has to walk past them. On our 100,000 posts:
| Query | Execution time |
|---|---|
ORDER BY created_at DESC LIMIT 50 OFFSET 90000 | 12.4 ms |
WHERE created_at < $1 ORDER BY created_at DESC LIMIT 50 | 0.06 ms |
The second one is Keyset pagination: remember the created_at of the last post on the page and ask for older ones. Every page costs the same. (It needs an index on the sort column and a tie-breaker such as id when two rows share a timestamp; Indexes covers the details.)
A transaction held open around lazy loads
Some frameworks open a transaction at the start of each request and commit at the end. Combine that with lazy loads during rendering, or a slow call to another service in the middle, and the transaction stays open for the whole request. All that time it holds a pool connection, and any rows it updated stay locked, so other requests that want them wait (see Race Conditions and Transactions).
Fix: load what you need, write what you must, commit, and only then render or call other services. Keep transactions short and free of network calls.
Keeping it fixed: tests that count queries
The trick is the same in every language: route all queries through something that counts them, run the code, and assert on the count. Run it against a real test database (an Integration test), since a fake database wouldn't run the real queries.
Go: count with a pgx tracer
pgx lets you attach a tracer to a connection config: an object that pgx calls at the start and end of every query. Ours just adds one to a counter:
// queryCounter counts every SQL statement pgx sends.
type queryCounter struct{ n atomic.Int64 }
func (c *queryCounter) TraceQueryStart(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryStartData) context.Context {
c.n.Add(1)
return ctx
}
func (c *queryCounter) TraceQueryEnd(context.Context, *pgx.Conn, pgx.TraceQueryEndData) {}
func TestListPostsQueryCount(t *testing.T) {
cfg, err := pgx.ParseConfig(os.Getenv("TEST_DATABASE_URL"))
if err != nil {
t.Fatal(err)
}
counter := &queryCounter{}
cfg.Tracer = counter // ← every query now passes through the counter
db := stdlib.OpenDB(*cfg) // a normal *sql.DB on top of pgx
defer db.Close()
before := counter.n.Load()
if _, err := ListPosts(context.Background(), db, 50); err != nil {
t.Fatal(err)
}
if got := counter.n.Load() - before; got > 2 {
t.Fatalf("ListPosts ran %d queries for 50 posts, want at most 2 (N+1?)", got)
}
}Pointed at an N+1 ListPosts (a plain database/sql version of the §03 loop: one SELECT name FROM authors WHERE id = $1 per post), the test fails with exactly the message you want to see in CI:
--- FAIL: TestListPostsQueryCount (0.04s)
posts_test.go:37: ListPosts ran 51 queries for 50 posts, want at most 2 (N+1?)
FAILJavaScript: wrap the pool, or listen to Prisma
import { test } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
import { listPosts } from "./posts.js";
// Wrap a pool so it counts every query that goes through it.
function countingPool(pool) {
const counter = { n: 0 };
const db = {
query: (...args) => { counter.n++; return pool.query(...args); },
};
return { db, counter };
}
test("listPosts does not run one query per post", async (t) => {
const pool = new pg.Pool({ connectionString: process.env.TEST_DATABASE_URL });
t.after(() => pool.end());
const { db, counter } = countingPool(pool);
await listPosts(db, 50);
assert.ok(counter.n <= 2, `listPosts ran ${counter.n} queries for 50 posts (N+1?)`);
});Run against the N+1 version, node's test runner reports error: 'listPosts ran 51 queries for 50 posts (N+1?)'. With Prisma, the loop version logged 6 queries for 5 posts and the include version 2.
Make the test about growth, not a magic number
"At most 2" breaks the day someone legitimately adds a third query. A sturdier test checks that the count doesn't depend on N: load a page with 2 posts, then with 20, and assert both ran the same number of queries. An N+1 fails that test whatever the exact numbers are.
Staff-level: when it's fine, caches, APIs and services
When N+1 is fine
The cost is N round trips, so the question is always: how big can N get, and how far away is the other side?
- N is small and bounded by your design, not your data. A settings page with 3 sections, a dashboard with 4 fixed widgets. Three extra 1 ms queries won't show up anywhere, and the simple code is worth more.
- Each "query" isn't a round trip. Lookups served from an in-memory cache in your own process cost microseconds. (A cache on another machine, like Redis, doesn't count; see below.)
- The page isn't hot. An internal admin report run twice a day can afford 200 ms.
The dangerous kind is the N that grows with your data: posts per page, items per order, projects per customer.
Caches are a band-aid
Putting each author lookup behind a Cache (say, Redis) feels like a fix. But if Redis is on another machine, you've swapped 50 database round trips for 50 cache round trips, each still around 0.2–1 ms. Faster, still a staircase. And now there's a second copy of the data that can go stale. If you do cache, batch the cache reads too: Redis MGET fetches many keys in one round trip, exactly like WHERE id = ANY. Fix the access pattern first; cache second. (More in Caching.)
N+1 over HTTP: the chatty API
The same shape between a client and your API costs far more per trip. Picture a mobile app showing 20 posts: GET /posts, then GET /authors/920, GET /authors/839… On a phone network a round trip is easily 50–100 ms (approximate; it varies widely).
Ways to give the client the whole screen in one trip:
- Embed related data:
GET /posts?include=authorreturns each post with its author inside. The server does the batching (§06) close to the database, where round trips are cheap. - Batch endpoints:
GET /authors?ids=920,839,758so a client that must fetch separately can at least fetch in one call. - A backend for frontend: a thin server per client app that assembles each screen's data from your services and returns it in one response.
- GraphQL with DataLoaders (§08): the client describes the whole tree; the server batches each level.
These are API design choices, so they're much cheaper to make before clients depend on the chatty version; see API Design and HTTP for the request costs involved.
N+1 between microservices
Split a system into microservices and the database JOIN that used to take 0.2 ms becomes a network call to another service. An orders page that calls the users service once per order is an N+1 over HTTP: each call costs a few milliseconds (connection, JSON encoding and decoding, the other service's own database query), so 50 orders add 100–250 ms. Worse, it multiplies load: one page view becomes 50 requests to the users service, and if those calls retry on timeouts, a slow users service gets hit even harder.
The fixes are the same ideas one level up: batch endpoints (POST /users:batchGet with a list of ids; the colon is a Google-API naming style for "a custom method", and POST /users/batch works just as well), a DataLoader-style batcher in the calling service, or keeping a local, read-only copy of the few user fields the orders service needs.
Check yourself
Predict the answer before you click. Q1 is covered in §03, Q2 in §09, Q3 in §08, Q4 in §05, Q5 in §07 and §10, Q6 in §12.
// 20 orders, each with 3 line items; every item has a product
const orders = await Order.findAll({ limit: 20 }); // lazy-loading ORM
for (const order of orders) {
for (const item of await order.items) {
console.log((await item.product).name);
}
}A page loads 50 posts, then each post's author one query at a time. Which of these changes still leaves the database running 50 author queries?
const loader = new DataLoader(async (ids) => {
console.log("batch", ids);
return ids.map((id) => ({ id }));
});
for (const id of ["a", "b", "c"]) {
await loader.load(id);
}A page loads posts with LEFT JOIN comments and LEFT JOIN post_tags in a single query. With test data (1 comment and 1 tag per post) it was instant. In production, where posts have ~20 comments and ~10 tags, it got 10× slower. Why?
// GORM
var posts []Post
db.Preload("Author").Limit(50).Find(&posts)
for _, p := range posts {
var n int64
db.Model(&Comment{}).Where("post_id = ?", p.ID).Count(&n)
fmt.Println(p.Title, p.Author.Name, n)
}A settings page loops over its 3 fixed sections (profile, billing, notifications) and runs one small query per section. It is opened a few times a day. Should you rewrite it to batch the queries?
Cheat sheet
WHERE id = ANY($1) → map by id → stitch. One query per relation, any N.include / Preload = the batch recipe automated. Name every relation the page reads.Promise.all / goroutines: same N queries, more connections held. Batch instead.| Situation | Do this |
|---|---|
| List + its "parent" (post → author) | JOIN, or GORM Joins |
| List + a has-many (post → comments) | LEFT JOIN if the parent is small, or a second query with = ANY($1) and a map, or include / Preload |
| Several has-many on one page | One ANY query per relation, or json_agg subqueries |
| Counts per row | SELECT post_id, count(*) … WHERE post_id = ANY($1) GROUP BY post_id |
| GraphQL / independent resolvers | DataLoader (JS), batcher with a short window (Go) |
| Inserting many rows | INSERT … SELECT FROM unnest($1::bigint[], …), createMany, db.Create(&slice); COPY for huge loads |
| Deep pagination | Keyset: WHERE created_at < $1 ORDER BY created_at DESC LIMIT n |
| Keep it fixed | Count queries in tests; assert the count doesn't grow with N |
| Client or service calls per item | Embed related data, batch endpoints, a BFF |