The N+1 Query Problem and How to Avoid It
A page that lists 50 blog posts along with each author's name feels instant in development, with a handful of test rows, and then falls over in production the moment real data shows up — not because the query is slow, but because it isn't one query. It's 51: one to get the posts, and one more per post to fetch its author. This is the N+1 problem, and it's one of the most common performance bugs in applications built on an ORM, precisely because the ORM makes it invisible until you go looking.
How it shows up
const posts = await Post.findAll(); // 1 query
for (const post of posts) {
post.author = await User.findByPk(post.authorId); // N queries, one per post
}
Nothing here looks wrong — it's readable, it's the obvious way to write it, and with 5 test posts it runs in milliseconds. With 5,000 posts in production, it's 5,001 round trips to the database, and round-trip latency (not query execution time) dominates. Even at a very fast 2ms per round trip, that's 10 seconds spent almost entirely waiting on network, for data a single well-formed query could return in one round trip.
Spotting it
The signature is a query count that scales with the size of a result set. Most ORMs and frameworks have a way to log every query issued per request — turn it on for a page you suspect, and count. If loading a list of 10 items issues roughly 11 queries, and a list of 100 items issues roughly 101, that's N+1.
// Sequelize example: log every query for the duration of one request
const posts = await Post.findAll({ logging: console.log });
Watching the same SELECT * FROM users WHERE id = ? shape repeat once per row, with only the parameter changing, is the tell.
The fix: eager loading
Load the related data in the same query, or in one batched follow-up query, instead of one query per row.
Join-based eager loading, using the ORM's built-in relationship loading:
// Sequelize
const posts = await Post.findAll({ include: User }); // 1 query, joined
// Prisma
const posts = await prisma.post.findMany({ include: { author: true } });
// Django ORM
posts = Post.objects.select_related('author')
This issues a single query with a JOIN, so the author data comes back attached to each post row in the same result set.
Batched loading, when a join isn't a good fit (fetching a large related collection per row, where joining would multiply row counts):
// 2 queries total, not N+1
const posts = await Post.findAll();
const authorIds = [...new Set(posts.map((p) => p.authorId))];
const authors = await User.findAll({ where: { id: authorIds } });
const authorsById = new Map(authors.map((a) => [a.id, a]));
for (const post of posts) {
post.author = authorsById.get(post.authorId);
}
One query for the posts, one query for every author referenced by those posts (deduplicated with the Set), then joined in application memory. Two round trips regardless of whether there are 10 posts or 10,000.
In raw SQL, the same idea
If you're not going through an ORM at all, the eager-loading equivalent is simply writing the join yourself instead of looping and querying:
-- N+1: one query per post, issued from application code in a loop
SELECT * FROM posts;
SELECT * FROM users WHERE id = ?; -- repeated per post
-- one query
SELECT posts.*, users.name AS author_name
FROM posts
JOIN users ON users.id = posts.author_id;
The N+1 problem isn't really an ORM problem — ORMs just make the loop-and-query pattern easy to write by accident, since post.author looking like a normal property access hides the query it triggers.
The nested case: N+1 one level deeper
It compounds when there's another relationship one level down — comments on each post, say:
const posts = await Post.findAll({ include: User }); // fixed the first N+1
for (const post of posts) {
post.comments = await Comment.findAll({ where: { postId: post.id } }); // reintroduced it
}
Fixing the author lookup doesn't help if comments are still fetched in a per-post loop. Eager-load nested relationships the same way:
const posts = await Post.findAll({ include: [User, Comment] });
When eager-loading everything is itself the problem
The inverse mistake exists too: eagerly loading every relationship on every query "just in case" pulls back far more data than most requests need, and can turn a cheap single-table lookup into a query joining across six tables every time. The fix isn't "always eager load" — it's loading what a given code path actually uses, which usually means being explicit about includes per-query rather than setting a blanket default.
Takeaway
N+1 queries are invisible in code review and in a small development dataset, and they only show up as a real problem once production-scale data makes the query count — not any single query's complexity — the bottleneck. Watch actual query logs against realistic data, not just query correctness against a handful of test rows, and reach for eager loading (a join or a batched follow-up query) the moment a loop is issuing a query per iteration.