
Last week I told you about how the day-one line that CREATE INDEX idx_users_email ON users(email); took a query from 4 seconds down to 12 milliseconds. This week's bug was sneakier. There was no slow query to find. When I checked the logs, every single query ran in under 2 milliseconds. Fast, fast, fast, fast — fifty times in a row. The endpoint was still crawling, and for a while I genuinely couldn't figure out why, because I kept looking for the one slow query that wasn't there.
The problem wasn't one slow query. It was fifty fast ones that had no business existing in the first place.
The wrong mental model almost everyone starts with
Most of us assume that if every individual database call is fast, the endpoint built on top of it must be fast too. That feels obviously true. It isn't.
An endpoint that runs 50 queries at 2 milliseconds each can lose to one that runs a single query at 30 milliseconds—because every query also pays a small "toll" just to travel between your app and the database before it even starts working. Fifty small tolls add up faster than people expect.
Quick gut-check before we go further
Say you fetch 20 blog posts, and for each post you also need the author's name. Your code looks something like this:
const posts = await db.query('SELECT * FROM posts LIMIT 20');
for (const post of posts) {
post.author = await db.query('SELECT * FROM users WHERE id = ?', [post.author_id]);
}
How many queries actually hit the database when this runs?
Take five seconds and guess before you scroll on. Most people say "20"; the real number is 21, and that gap between what you'd guess and what actually happens is the whole problem, in miniature.
What's actually happening
That first query gets your 20 posts — that's the "1" in N+1. Then, for every single post, your loop fires off a separate query to grab that post's author — that's the "N." Twenty posts, twenty extra queries, twenty-one total. Bump it to 500 posts on a busier page, and you're now running 501 queries to render one screen.

Here's what makes this bug so easy to miss: it's invisible in development. With 5 posts in your local database, nobody notices 6 queries firing instead of 1 — it still feels instant. The bug only becomes visible in production, with real data, under real load, which is exactly the worst time to discover it.
Why ORMs make this worse, not better
This bug rarely shows up in raw SQL. It shows up in ORMs like Hibernate because they're built to make relationships feel effortless. You write, post.author.name and it just works, no query in sight. But behind that one line, the ORM is quietly running a fresh query the moment you touch.author, for every post, every single time. The code looks completely innocent. That's exactly why this is one of the most common performance bugs in real production apps, not a beginner-only mistake.
A real before-and-after
On a dashboard page, we listed 50 posts, and for each one, showed the author's name and a comment count. Nobody had written it maliciously—it was just three lines inside a loop that each felt harmless on their own. Query logging told a different story: 101 queries for a single page load. One for the posts, fifty for the authors, fifty for the comment counts.
The fix took under twenty minutes. We batched the author lookups into a single WHERE id IN (...) query and did the same for comment counts using a GROUP BY post_id. That brought it down to 3 queries total—one for posts, one for authors, and one for comment counts. Response time on that endpoint dropped, and none of the actual business logic changed. Just how many round trips it took to get the same data.
That's usually how N+1 fixes go. Small change in code, big change in a number nobody was watching.
The fix and the trade-off nobody mentions
The fix is almost always some form of "ask for everything up front instead of asking one item at a time." In SQL, that's a JOIN. In most ORMs, it's a method with a name like .include(), .with(), .select_related(), or @EntityGraph all doing the same thing: telling the ORM, "Fetch the related data in the same trip; don't make me ask for it later."
But joining isn't automatically free either. If one post has 40 comments, a naive join duplicates that post's data 40 times in the result set—trading fewer round trips for a wider, heavier response. For one-to-one relationships, like a post and its single author, a join is usually a clear win. For one-to-many relationships, like a post and its comments, batching one query for posts, one query for "all comments where post_id IN (...)" often beats a join, because it avoids that duplication entirely.
Catching it before it ships
The cheapest place to fix an N+1 is before it ever reaches production, and it doesn't take fancy tooling:
Watch for database calls inside loops. Any time you see await or .find() sitting inside a for, map, or forEach over a list, stop and check what it's actually doing.
Add a query-count assertion to your tests. Most frameworks let you count queries per request in a test environment. A simple test that fails if a request fires more than, say, 5 queries will catch most N+1s before a human ever has to notice.
Use a detector if your framework has one. Rails has the bullet gem. Django has django-silk even a basic middleware that logs total query count per request and flags anything unusually high works surprisingly well.
Read PRs with this specific question in mind: "If this list has 500 items instead of 5, what happens?" That single question catches more N+1s in code review than almost anything else.
Once you've hunted one N+1 down, you start seeing the same shape everywhere: a loop that quietly means "go ask the database again." It's one of those bugs that, once you can see them, you can't stop seeing. In a good way. If you haven't already, the indexing article this one follows on from is worth reading first; a lot of what makes batching and joins fast comes down to the same B-tree fundamentals.
Top comments (0)