The N+1 Query Problem: How to Spot It and Fix It in Django and SQLAlchemy
How the N+1 query problem silently tanks production performance, why it's invisible in dev, and concrete fixes for Django and SQLAlchemy.
I've lost count of how many "why is this page slow" incidents I've walked into over the years that turned out to be the exact same root cause: an ORM quietly issuing hundreds of queries to render a list of a few dozen objects. It's one of the most common performance bugs in web applications, almost invisible in development with small datasets, and a production incident waiting to happen once real traffic and real data volumes show up.
This is the N+1 query problem. It's old, it's well documented, and engineers still ship it constantly — including me, early in my career. Let's fix that.
What is the N+1 query problem?
The N+1 query problem happens when your code runs one query to fetch a list of N records, then runs one additional query per record to fetch related data — giving you N+1 total queries where one or two would have done the job.
Classic example: you fetch 50 blog posts with a single query, then loop over them to print each author's name. If your ORM lazily loads the author relationship, that's 50 additional queries — one per post — plus the original query for the posts themselves. 51 queries to render a page that should take 1 or 2.
This scales badly in a very specific way: it's invisible with 5 test records in your local database, mildly annoying with 50, and a full-blown latency incident with 5,000. It passes code review, passes QA, and then pages you at 2am because a client with a large dataset just loaded their dashboard.
The problem isn't unique to any one language, framework, or ORM. It shows up in Django, SQLAlchemy, JPA/Hibernate, ActiveRecord, Prisma, Sequelize — anywhere an ORM abstracts away the decision of "when do I fetch related data." The abstraction is the point of an ORM, but it's also exactly where this bug hides.
N+1 query problem with plain SQL
It's worth grounding this in plain SQL first, because the ORM version is just this pattern wearing a costume.
Say you want a list of orders along with each customer's name. The naive, N+1-shaped approach looks like this:
If that first query returns 200 pending orders, your application layer now fires off 200 separate SELECT name FROM customers WHERE id = ? queries in a loop. Each one is fast in isolation — a few milliseconds, maybe less with an index on customers.id — but the round-trip latency adds up. Even at a conservative 2ms per round trip, 200 sequential queries is 400ms of pure network and connection overhead before you've done any real work with the data.
The fix in plain SQL is obvious once you see it: use a JOIN or a single WHERE IN query instead.
Or, if you want to keep the shapes separate (useful when hydrating objects on the application side rather than flattening rows):
That second pattern is precisely what a well-configured ORM does under the hood when you tell it to eager-load a relationship. Understanding this plain-SQL version is what makes the ORM-specific fixes make sense instead of feeling like framework trivia.
N+1 queries in Django
Django's ORM makes it trivially easy to write N+1 queries because relationship access looks exactly like a normal attribute access. There's no visual cue that post.author.name is about to hit the database.
That's N+1 queries for a page listing N posts. It reads like perfectly reasonable Python, which is exactly the trap.
Django gives you two main tools to collapse this back down to a constant number of queries:
select_related() — for forward foreign key and one-to-one relationships. It uses a SQL JOIN and pulls everything into a single query.
prefetch_related() — for many-to-many and reverse foreign key relationships, where a JOIN would multiply rows unhelpfully. Django runs a second query with a WHERE IN and stitches the results together in Python.
The rule of thumb: select_related when going "down" a foreign key (post → author), prefetch_related when going "out" to a collection (post → tags, or author → posts). Get this backwards and you'll either error or silently revert to N+1 behaviour.
You can chain both on the same queryset, and you can go multiple levels deep by separating relationship names with a double underscore:
Prefetch() for filtered or ordered relations
Plain prefetch_related('comments') fetches all related rows. If you only need a subset — say, the 5 most recent comments per post, or only approved ones — a bare string won't express that, and you'll be tempted to filter in Python, which defeats the purpose. Django's Prefetch object lets you attach a custom queryset to the prefetch itself:
This is the tool to reach for once your "just eager-load it" fix runs into a real-world requirement like pagination, filtering, or ordering on the related set.
Watch for N+1 in serializers, not just templates
The examples above are template-shaped, but in practice this bug shows up just as often — arguably more often now — in DRF (Django REST Framework) serializers, where a nested serializer field silently triggers a query per object:
If the view's queryset doesn't call select_related('author'), every serialized post triggers a fresh query for its author, and it's much easier to miss than the equivalent template loop because the serializer code doesn't look like a loop at all — DRF does the iterating for you. Always set select_related/prefetch_related on the queryset in the view or get_queryset(), not just in the serializer's Meta.
Django's django-debug-toolbar is the single best tool for catching this in local development — it shows you the exact query count and duplicated queries per request. If you're not running it in dev, add it today.
N+1 queries in SQLAlchemy
SQLAlchemy has the same failure mode and the same category of fix, expressed through relationship loading strategies.
By default, SQLAlchemy relationships are lazily loaded — the query for post.author fires the moment you access the attribute. Fine for a single object, disastrous in a loop over hundreds.
The fix is to specify a loading strategy at query time:
joinedload behaves like Django's select_related — one query, a JOIN, best for singular relationships. selectinload behaves like prefetch_related — a second batched query, best for collections, and generally the safer default when unsure, because it avoids the row-multiplication problem that a JOIN against a one-to-many relationship can cause.
Catching accidental lazy loads with raiseload
Eager-loading the relationships you know you need is half the fix; the other half is making sure nobody accidentally reintroduces a lazy load somewhere you didn't expect it — in a serializer, a template, a background task that reuses the same query. SQLAlchemy's raiseload option turns an unexpected lazy load into a loud InvalidRequestError instead of a silent extra query, which is exactly what you want in tests and, arguably, in production paths where you've already committed to a fetch strategy:
With raiseload('*') set, any relationship access that wasn't explicitly eager-loaded raises immediately instead of quietly issuing a query — turning a performance bug into a test failure, which is a much better place to catch it. (See SQLAlchemy's Relationship Loading Techniques docs for the full set of loader strategies, including subqueryload and per-attribute wildcard loading.)
N+1 query problem with JPA and Hibernate
If you're working in a polyglot shop where some services are Spring Boot and others are Django or FastAPI, understand the JPA/Hibernate version — the underlying problem is identical, but the configuration surface is different and easier to get wrong by default.
In JPA, relationships are annotated with a fetch type:
FetchType.EAGER
FetchType.EAGER tells Hibernate to load the related entity immediately, whether you access it or not. It sounds safe — no lazy-loading surprises — but it's a trap in the opposite direction: you fetch far more data than a given code path needs, on every single fetch of the parent entity. You can't opt out per-query without extra work.
Historically, @ManyToOne and @OneToOne default to EAGER in the JPA spec, which surprises people who assume everything is lazy. Check your entity mappings explicitly rather than assuming.
FetchType.LAZY
FetchType.LAZY defers loading the relationship until it's accessed — the direct cause of N+1 queries when you iterate a collection and touch a lazy relationship on each item. @OneToMany and @ManyToMany default to LAZY.
The Hibernate-idiomatic fix is a JOIN FETCH in your JPQL, which behaves like Django's select_related or SQLAlchemy's joinedload — it forces the related entities into the same query:
For collections, Hibernate also supports @BatchSize, which doesn't eliminate extra queries but batches them — instead of one query per parent entity, it issues one query per batch of N parent entities, a meaningful middle ground when a full JOIN FETCH would multiply rows.
The pattern across all three ecosystems is the same: lazy-by-default relationship loading is correct and necessary in general, but you must explicitly opt into batching or joining wherever you know in advance you'll need the related data for every row in a result set.
How to automatically detect the N+1 query issue
Manually reading SQL logs works, but doesn't scale across a team. The tools I actually rely on:
- Django:
django-debug-toolbarfor local dev, andnplusone(or similar libraries) which can raise an exception in tests when a lazy load happens inside a loop — turning a silent bug into a failing CI test.QuerySet.explain()is also worth knowing — it prints the database's actual query plan (EXPLAINunder the hood) for a given queryset, which is useful once you've fixed the query count and want to know whether the resulting joins are actually using the indexes you think they are. - SQLAlchemy: turn on
echo=Trueon your engine in development to see every statement, or hook into SQLAlchemy's event system (before_cursor_execute) to count queries per request and log a warning above a threshold.raiseload('*')(above) is the more aggressive option — it fails fast instead of just logging. - JPA/Hibernate: enable SQL logging (
show-sqlplus statistics), or use Hibernate'sSessionFactorystatistics API, which tells you exact query counts per session — useful for catching regressions in integration tests. - Framework-agnostic: an APM tool (Datadog, New Relic, or similar) attached to your production traffic will show you query counts per endpoint over time. The pattern to look for is a query count that scales linearly with response payload size.
My actual practice: bake a query-count assertion into integration tests for any endpoint that returns a list. Something as simple as "this endpoint must not issue more than 5 queries regardless of how many rows it returns" catches regressions before code review, let alone production.
Django ships the pieces to write this for real, not just as pseudocode — django.test.utils.CaptureQueriesContext or the assertNumQueries() method on TestCase will do the counting for you:
N+1 fixes vs. connection pooling: different layers, both matter
It's worth being clear about what fixing N+1 queries does and doesn't solve, especially if you've already tuned connection pooling (with something like PgBouncer) or tuned Postgres itself. Pooling reduces the cost of opening a connection; it does nothing about the number of queries you're issuing per request. A pooled connection still has to do a network round trip and a query-planning pass for every single one of those N+1 queries — pooling just means you're not also paying to spin up a fresh Postgres backend process for each one. Fix both layers: pool your connections so each query is cheap to issue, and fix your fetch strategy so you're not issuing hundreds of them in the first place. Neither one substitutes for the other.
Closing thoughts
The N+1 query problem isn't a framework bug — it's an inherent consequence of how ORMs abstract relationship loading. Every ORM makes the same trade-off: convenient by default, expensive at scale unless you're deliberate about fetch strategy. The fix is never complicated once you see it — it's always some flavor of "batch the related fetch instead of looping it" — but you have to actually look at your query logs to see it in the first place. Turn on logging in development, add a query-count check to your test suite, and treat "how many queries does this endpoint run" as a number you know, not a number you hope is small.
FAQ
Is the N+1 query problem only an ORM issue? No. It's a data-access pattern problem that ORMs happen to make easy to trigger accidentally, because relationship access looks like a free attribute lookup. You can write N+1 queries in raw SQL by looping and querying inside the loop just as easily.
Does eager loading always fix the problem? It fixes the query-count problem but introduces a new trade-off: you may fetch more data than a given request path needs. Blanket eager loading can bloat query payloads and memory usage. The right fix is loading exactly what each specific code path needs.
Can caching fix N+1 queries instead of fixing the query pattern? Caching can mask the symptom for repeated requests, but it doesn't fix the underlying cost on cache misses or unique requests — and it adds cache-invalidation complexity you didn't have before. Fix the query pattern first; add caching afterward for the workloads that still need it.
Does select_related or joinedload ever make things slower?
Yes — mainly by over-fetching. Joining in five relationships you don't need on every request inflates each row with columns you're discarding, and joining into a one-to-many relationship duplicates the parent row once per child, which can multiply your result set size in ways that are worse than the N+1 queries you were trying to avoid. This is exactly why selectinload/prefetch_related exist as the batched, non-multiplying alternative for collections — reach for a join only when the relationship is singular (many-to-one or one-to-one).
How many queries is "too many" for a single request? There's no universal number, but a useful heuristic: your query count should not scale with the number of rows returned. An endpoint that returns 10 posts and one that returns 1,000 posts should issue roughly the same number of queries (one for posts, one or two per eager-loaded relationship) — not one that grows linearly with the row count. If it does grow with N, you have an N+1 somewhere, even if the absolute number looks small in a quick manual test.
Damian Hodgkiss
Senior Staff Engineer at Sumo Group, leading development of AppSumo marketplace. Technical solopreneur with 25+ years of experience building SaaS products.