Your page makes one query. Then it makes a hundred more. What N+1 is, why it hides so well, and the few ways to fix it in any stack.
Select any sentence in this post to leave a margin note on it.
Did this land?
Some bugs don't look like bugs.
The page works. The data is right. Tests pass. Locally it loads instantly, because your database has twelve rows. Then it hits production, the table has forty thousand rows, and the page takes four seconds.
Nothing is broken. You're just asking the database the same question a few hundred times.
That's N+1.
Say you want a list of posts with each author's name. The obvious code:
const posts = await db.query("SELECT * FROM posts LIMIT 50");
for (const post of posts) {
post.author = await db.query(
"SELECT * FROM users WHERE id = $1",
[post.author_id],
);
}One query for the posts. Then one more for every post.
That's 1 + N queries. Fifty posts, fifty-one round trips. It's called N+1 because people like writing it backwards.
Every single one of those queries is fast. That's exactly why it hides. Nothing in your logs looks slow. It's death by a thousand perfectly reasonable queries.
Here's what that looks like once the database isn't on your laptop. Pick a strategy, drag the rows up, press run.
SELECT * FROM posts LIMIT 50 SELECT * FROM users WHERE id = $1 -- 17 SELECT * FROM users WHERE id = $1 -- 34 SELECT * FROM users WHERE id = $1 -- 51 -- ...and 47 more, identical except for the id
Watch the latency slider more than the row count. On your machine a round trip is basically free, so fifty extra queries cost nothing you'd notice. Put the database in another region and the same code takes seconds.
In that example it's obvious, the loop is right there. In real code it almost never is.
ORMs make relations look like normal fields:
for (const post of posts) {
console.log(post.author.name);
}That reads like accessing a property. With lazy loading, every post.author can be a brand new query. The code causing the problem and the code that looks harmless are the same code.
And it's often not even a loop you wrote. It's a template rendering a list. A GraphQL resolver fetching each author on its own. A serializer that helpfully includes nested objects. A helper that does one query, called from inside someone else's .map(). The query lives in one file, the loop in another, and nobody wrote both at the same time.
You won't find it by reading code. You find it by watching what the database actually receives.
Turn on query logging in development. Every ORM and driver has a flag for it. Load one page and count. If one request produces fifty lines that are identical except for an ID, there it is.
Test with real amounts of data, too. Twelve rows will never show you this. Seed a few thousand.
The rule I go by is simple: the number of queries shouldn't grow with the number of rows.
There are really only three fixes. Every ORM just spells them differently.
Ask for everything at once:
SELECT posts.*, users.name AS author_name
FROM posts
JOIN users ON users.id = posts.author_id
LIMIT 50;One query. Great for one-to-one, like a post and its author. It gets awkward for one-to-many, because rows multiply. A post with ten comments comes back ten times.
Fetch the parents, collect the IDs, fetch the children in one go:
const posts = await db.query("SELECT * FROM posts LIMIT 50");
const ids = [...new Set(posts.map((p) => p.author_id))];
const authors = await db.query(
"SELECT * FROM users WHERE id = ANY($1)",
[ids],
);
const byId = new Map(authors.map((a
Two queries, whether there are fifty posts or five thousand. This is what your ORM does when you ask it to "include" or "eager load" a relation.
When the code that needs the data is scattered, like GraphQL resolvers, you can't easily collect the IDs yourself. A data loader does it for you. It waits for every request for a user in the same tick, then sends one batched query. Same idea as IN, just automatic.
Once you learn about N+1, it's tempting to eager load everything, everywhere.
Don't. Pulling every relation on every request is its own problem. Huge payloads, wide joins, memory spent on data nobody reads.
The goal isn't the fewest possible queries. It's a predictable number that doesn't depend on how much data there is. Two queries for a list is fine. Two plus one per row is the bug.
Best time to catch it is the first time you write a list page. Second best is before the table gets big.