The N+1 Query Problem: What It Is and How to Fix It
The N+1 query problem fires one query per row instead of one query total, quietly turning a fast page into hundreds of round trips. How to spot and fix it.
The N+1 query problem is a performance bug where code runs one query to fetch a list of N records, then runs one additional query per record to fetch related data — N+1 queries total instead of the two or one it should take. It’s one of the most common performance issues in applications that use an ORM, because ORMs make it very easy to write code that looks innocent but triggers a query on every iteration of a loop.
How it happens
Say you’re rendering a blog’s homepage: a list of posts, each showing its author’s name. A natural-looking implementation in ORM-style pseudocode:
posts = Post.all() -- 1 query: fetch all posts
for post in posts:
print(post.author.name) -- 1 query per post: fetch that post's author
If there are 50 posts, this runs 1 query to get the posts, then 50 more — one per post — to get each author, for 51 total. That’s the “N+1”: one query for the list, N queries for the related data, where N is the number of rows.
The reason this is so common is that the code reads perfectly naturally. post.author.name looks like a simple attribute access, not a database round trip. Most ORMs use lazy loading by default: related objects aren’t fetched until you actually touch them, and each touch is a fresh query. Nothing about the syntax signals that a network call is about to happen.
Why it’s expensive
A single query and a single network round trip are fast. Fifty of them, each paying its own connection overhead, query planning, and network latency, are not — even if each individual query only takes a few milliseconds. The cost is multiplicative with data size: a page that’s fine with 10 rows in development can become unusably slow with 10,000 rows in production, because the query count scales linearly with the row count instead of staying constant.
This is a common way database indexing optimization efforts miss the real problem — indexing can make each of the N queries faster, but it can’t fix the fact that there are N of them when there should be one.
Fixing it: eager loading
The fix is to tell the ORM up front that you’ll need the related data, so it can fetch everything in a batch instead of one row at a time. This is usually called eager loading, and most ORMs support it with a method or option:
posts = Post.all().include(author) -- eager-load authors
for post in posts:
print(post.author.name) -- no extra query; already loaded
Under the hood, this typically becomes either:
- A single JOIN query — fetch posts and authors together in one query, denormalized into a wider result set.
- A batched second query — fetch all posts, collect their author IDs, and run one
WHERE id IN (...)query for all the needed authors at once.
Both approaches turn N+1 queries into 1 or 2. The join is usually faster for small related datasets; the batched IN query avoids duplicating post data across rows and scales better when the relationship is one-to-many in the other direction.
Nested relationships compound the problem if each level is left lazy: posts to authors to author profile pictures, each fetched one row at a time, turns one page load into hundreds of queries instead of dozens. Most ORMs support eager-loading multiple levels deep in a single call, which is worth doing explicitly for any relationship chain a page actually renders, rather than discovering the fan-out by watching query logs after the fact.
Spotting it before it ships
N+1 problems are notoriously easy to miss in development, where the row counts are small and the extra queries add negligible latency. A few practices catch it earlier:
- Query logging in development. Most frameworks can log every query issued during a request. A page that logs one query per list item is an immediate tell.
- Query count assertions in tests. Some test suites assert an upper bound on query count for a given endpoint, so a regression that reintroduces N+1 behavior fails CI rather than shipping.
- APM tooling in production. Application performance monitoring tools that trace database calls per request will show a suspicious fan-out pattern — one request generating dozens of near-identical queries — even if it wasn’t caught in testing.
When N+1 is actually fine
Eager-loading everything by default isn’t free either — it can pull in far more data than a given request actually needs, especially when a list only sometimes needs the related field. If a page renders 5 items and rarely needs the related data at all, N+1’s extra queries might genuinely cost less than always joining in data that’s usually unused. The fix should match the access pattern: eager-load relationships you know you’ll use on every row, and leave rarely-needed ones lazy.
The takeaway
N+1 queries happen when code fetches a list, then fetches related data one row at a time inside a loop — a pattern that ORMs make easy to write without noticing. The fix is eager loading: batch the related-data fetch into a join or a single IN query instead of firing one query per row. Catch it with query logging in development and query-count checks in CI, since it’s invisible at small scale and expensive at production scale.
Tagged
Keep reading
The Lycoris Team · · 5 min read Clustered vs Non-Clustered Index Explained
A clustered index determines the physical row order on disk; a non-clustered index is a separate lookup structure. How they differ and when to use each.
The Lycoris Team · · 5 min read Hash Join vs Nested Loop Join vs Merge Join
Hash joins, nested loop joins, and merge joins are the three ways a database executes a JOIN — here's when the query planner picks each one.
The Lycoris Team · · 4 min read What Is a Composite Index in a Database?
A composite index spans multiple columns in a fixed order, speeding up queries that filter or sort on that combination. How column order changes everything.