The ORM N+1 Query Problem: Why It Ships to Production Undetected
The page loads fine on your laptop. It loads fine in staging. It ships. Three weeks later, once real data has accumulated, that same page takes four seconds and someone opens an incident. You look at the database and find it: a page that should issue two queries is issuing two hundred and one.
This is the N+1 query problem, and it is the single most common performance bug I see in ORM-backed applications. Not because it is subtle to fix, it is not, but because nothing catches it before production. The tools that detect it run in development. The seed data that would expose it is too small. So it ships quietly, behaves perfectly under ten rows, and only bares its teeth when a real customer has two hundred.
The frustrating part is that the fix is usually one line. The hard part is knowing it is there at all.
N+1 happens when fetching N parent rows lazily loads each parent's related rows one query at a time, issuing 1 + N queries instead of 1 or 2.
- Fix it with eager or batched loading: Prisma
include, Sequelizeinclude, Djangoselect_related/prefetch_related, SQLAlchemyjoinedload/selectinload, HibernateJOIN FETCH. - Detect it by turning on ORM query logging and watching for the same query repeated with different IDs.
- Prevent regressions by writing a test that asserts the query count for the endpoint.
What N+1 looks like in the query log
You do not get an error message. You get a query log that repeats. Turn on logging and the signature is unmistakable: one query to fetch the list, then one nearly identical query per row, differing only by the id.
-- The 1: fetch the list
SELECT * FROM posts LIMIT 200;
-- The N: one lookup per post, 200 times
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
-- ... 197 more ...
SELECT * FROM users WHERE id = 200;
That is 201 round trips to the database to render one list of posts with their authors. Each round trip has network latency, connection overhead, and query planning attached, and they run in sequence. The database is not slow; you are asking it two hundred separate times for data you could have fetched once.
Why it ships to production undetected
N+1 is not an accident in your code so much as the default behaviour of every lazy-loading ORM. When you write post.author.name inside a loop, the ORM dutifully fetches that author the moment you touch it, one query at a time, exactly as designed. The code reads perfectly naturally. Nothing about it looks wrong.
Three things then conspire to keep it hidden until production:
- Small dev data. With five seeded rows, 1 + 5 is six queries and the page renders instantly. The bug is present and invisible. It scales linearly with data, so it only becomes a problem at the volume you do not have locally.
- Detection tools live in development. Django Debug Toolbar, the
bulletgem in Ruby, and Python'snplusoneall exist and all work well, but almost none of them run in production by default, and most teams never wire them into CI. The one place the bug matters is the one place nobody is watching for it. - No error is ever raised. The query count is a performance property, not a correctness one. Everything returns the right data. It is just slow, and slow does not fail a test that only checks the response body.
So the pattern reaches production the way most performance bugs do: it is caught by a customer or a dashboard during an incident, not by a reviewer reading the diff. If you have ever chased a mysteriously slow endpoint, this belongs on the same shortlist as a slow query hiding in the log.
The fix: eager or batched loading
Every ORM gives you a way to say "fetch the related rows up front, in one or two queries, not one per row." The names differ; the idea is identical.
// Prisma: include pulls authors in a batched query
const posts = await prisma.post.findMany({
take: 200,
include: { author: true }, // one extra query, not 200
})
# Django: select_related for FK/one-to-one (SQL JOIN),
# prefetch_related for many-to-many / reverse FK (separate batched query)
posts = Post.objects.select_related("author")[:200]
authors = Author.objects.prefetch_related("books")[:200]
# SQLAlchemy: selectinload issues one IN() query for the related rows
from sqlalchemy.orm import selectinload
posts = session.execute(
select(Post).options(selectinload(Post.author)).limit(200)
).scalars().all()
| ORM | Eager load | Notes |
|---|---|---|
| Prisma | include / select | Batches related rows automatically |
| Sequelize | include | Uses a JOIN by default |
| Django | select_related / prefetch_related | JOIN vs separate batched query |
| SQLAlchemy | joinedload / selectinload | JOIN vs IN() query |
| Hibernate | JOIN FETCH / @EntityGraph | Fetch plan on the query |
Note the recurring split: a JOIN-based strategy (select_related, joinedload, JOIN FETCH) folds everything into one query, which is ideal for to-one relationships. A separate-batched-query strategy (prefetch_related, selectinload) issues a second query with an IN (...) clause, which is better for to-many relationships where a JOIN would multiply your rows. Reach for the batched form when a JOIN would produce a cartesian explosion.
Detect it: turn on query logging
You cannot fix what you cannot see, and the whole reason N+1 survives is that nobody is looking at the queries. Turn on logging in development and watch the count for any endpoint that loads a list with relationships:
// Prisma: log every query to the console
const prisma = new PrismaClient({ log: ['query'] })
# SQLAlchemy: echo the SQL it issues
engine = create_engine(url, echo=True)
# Django: log the DB backend queries
LOGGING = {"loggers": {"django.db.backends": {"level": "DEBUG", "handlers": ["console"]}}}
Load the page, count the queries. If you see the same statement repeated with different bound ids, you have found it. This is the same "read what the database is actually being asked to do" instinct that solves a slow SELECT that disk speed cannot explain.
Prevent regressions: assert the query count in a test
The durable fix is not just eager-loading today's query; it is making a reintroduction fail CI. Most ORMs let you assert how many queries a block issues, which turns an invisible performance property into a visible, tested one.
# Django ships this out of the box
def test_post_list_query_count(self):
with self.assertNumQueries(2): # fails at 201
response = self.client.get("/posts")
self.assertEqual(response.status_code, 200)
Now if someone later adds post.tags inside the template and reintroduces an N+1, the test goes red on the pull request instead of the pager going off in production. That single test is worth more than any amount of after-the-fact profiling.
Verify the fix worked
1. With query logging on, load the endpoint before the fix. Count: 201.
2. Add the eager load (include / select_related / selectinload).
3. Reload. Count should drop to 2 (or 1 for a JOIN strategy).
4. Confirm the response body is identical to before, not just faster.
5. Lock it in with an assertNumQueries-style test.
Point 4 matters: an eager load should change how the data is fetched, never what data comes back. If the response changed, you altered the query semantics, not just its efficiency.
What people get wrong
Eager-loading everything, everywhere. The overcorrection. Blanket include on every query fetches columns and relations you never use, and JOIN-loading several to-many relationships at once produces a cartesian product that is slower than the N+1 you started with. Load what the endpoint actually renders, and use batched loading for to-many links.
Caching the slow query instead of fixing it. Bolting a cache in front of a 201-query endpoint hides the cost until a cache miss, at which point one unlucky request pays all of it. Fix the query first; cache the correct query second if you still need to.
Raising the connection pool to cope with query volume. If the endpoint fires 200 queries, a bigger pool just lets more of that happen concurrently and moves the bottleneck to the database. That is how N+1 turns into connection pool timeouts. Reduce the queries, do not widen the pipe feeding them.
When it is still slow
- Nested N+1. You eager-loaded authors, but now each author's profile is lazy-loaded per row. Check for a second layer and eager-load it too (nested
include, chainedprefetch_related). - A JOIN cartesian explosion. If you JOIN-loaded a to-many relation and the row count ballooned, switch that relation to the batched strategy (
selectinload,prefetch_related) so it runs as a separateIN (...)query. - A relation accessed outside the loaded scope. Touching a relation the ORM did not fetch (in a serializer, a template, a computed property) re-triggers lazy loading even after you added the eager load higher up. Trace where the extra query is issued, not where you thought you fixed it.
- The query is genuinely heavy. If you are down to two queries and one is still slow, this is no longer N+1; it is a query that needs an index or a rewrite. That is a different playbook.
The lasting fix for N+1 is not a command, it is a habit: look at the query log for any endpoint that loads a list with relationships, and put a query-count assertion around it. Detection is the whole game. Once you can see the 201, fixing it down to 2 is the easy part.
Frequently asked questions
- What is the N+1 query problem in an ORM?
- It is when fetching N parent rows causes the ORM to lazily load each parent's related rows one query at a time, issuing 1 + N queries instead of 1 or 2. For example, listing 200 posts and accessing each post's author fires one query for the posts and 200 more for the authors, 201 round trips to render one list. The data is correct; it is just needlessly slow.
- Why does N+1 only show up in production and not in development?
- Because it scales with data volume and detection tools run in development. With five seeded rows, 1 + 5 queries renders instantly, so the bug is present but invisible locally. Tools like Django Debug Toolbar, the bullet gem, and nplusone catch it in development but rarely run in production or CI, and no error is ever raised because query count is a performance property, not a correctness one.
- How do I fix an N+1 query?
- Use eager or batched loading so the related rows are fetched up front. That is include or select in Prisma, include in Sequelize, select_related or prefetch_related in Django, joinedload or selectinload in SQLAlchemy, and JOIN FETCH or @EntityGraph in Hibernate. Use a JOIN strategy for to-one relations and a separate batched IN() query for to-many relations to avoid a cartesian explosion.
- How can I stop N+1 queries from coming back?
- Write a test that asserts the query count for the endpoint. Django provides assertNumQueries out of the box, and other ORMs let you count queries via query logging hooks. If someone later reintroduces a lazy relation access, the test fails on the pull request instead of the problem reaching production. Detection is the hard part; once the count is visible and tested, the fix stays fixed.