DH
14 min read

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.

djangoperformancepostgres

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:

-- Query 1: get all orders
SELECT id, customer_id, total FROM orders WHERE status = 'pending';

-- Then, for each row returned above, run:
SELECT name FROM customers WHERE id = ?;

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.

-- One query, joined
SELECT orders.id, orders.total, customers.name
FROM orders
JOIN customers ON customers.id = orders.customer_id
WHERE orders.status = 'pending';

Or, if you want to keep the shapes separate (useful when hydrating objects on the application side rather than flattening rows):

-- Query 1
SELECT id, customer_id, total FROM orders WHERE status = 'pending';

-- Query 2 — batch fetch, not N fetches
SELECT id, name FROM customers WHERE id IN (?, ?, ?, ...);

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.

# N+1 in the wild
posts = Post.objects.filter(published=True) # 1 query

for post in posts:
print(post.author.name) # 1 query PER post

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.

posts = Post.objects.filter(published=True).select_related('author')

for post in posts:
print(post.author.name) # no extra query — already loaded

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.

posts = Post.objects.filter(published=True).prefetch_related('tags')

for post in posts:
print([tag.name for tag in post.tags.all()]) # no extra query

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:

posts = (
Post.objects
.select_related('author') # forward FK, one JOIN
.prefetch_related('tags', 'comments__user') # collections, batched separately
)

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:

from django.db.models import Prefetch

posts = Post.objects.prefetch_related(
Prefetch(
'comments',
queryset=Comment.objects.filter(approved=True).order_by('-created_at'),
to_attr='recent_comments',
)
)

for post in posts:
for comment in post.recent_comments: # already filtered, no extra query
print(comment.body)

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:

class PostSerializer(serializers.ModelSerializer):
author_name = serializers.CharField(source='author.name') # looks free, isn't

class Meta:
model = Post
fields = ['id', 'title', 'author_name']

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.

# N+1 in the wild
posts = session.query(Post).filter(Post.published == True).all() # 1 query

for post in posts:
print(post.author.name) # 1 query PER post, lazy-loaded

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:

from sqlalchemy.orm import joinedload, selectinload

# joinedload: single query with a SQL JOIN — good for many-to-one
posts = (
session.query(Post)
.options(joinedload(Post.author))
.filter(Post.published == True)
.all()
)

# selectinload: two queries total, using WHERE IN — good for one-to-many/many-to-many
posts = (
session.query(Post)
.options(selectinload(Post.tags))
.filter(Post.published == True)
.all()
)

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:

from sqlalchemy.orm import raiseload

posts = (
session.query(Post)
.options(joinedload(Post.author), raiseload('*'))
.filter(Post.published == True)
.all()
)

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:

@Entity
public class Post {
@ManyToOne(fetch = FetchType.LAZY)
private Author author;

@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
private List<Comment> comments;
}

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:

@Query("SELECT p FROM Post p JOIN FETCH p.author WHERE p.published = true")
List<Post> findPublishedWithAuthor();

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-toolbar for local dev, and nplusone (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 (EXPLAIN under 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=True on 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-sql plus statistics), or use Hibernate's SessionFactory statistics 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.

# Pseudocode example — adjust to your test framework
def test_list_posts_query_efficiency():
with assert_query_count_below(5):
response = client.get('/api/posts?limit=100')
assert response.status_code == 200

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:

from django.test import TestCase

class PostListQueryCountTests(TestCase):
def test_list_posts_query_efficiency(self):
with self.assertNumQueries(3): # posts + authors + tags, not N of either
response = self.client.get('/api/posts?limit=100')
self.assertEqual(response.status_code, 200)

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

Damian Hodgkiss

Senior Staff Engineer at Sumo Group, leading development of AppSumo marketplace. Technical solopreneur with 25+ years of experience building SaaS products.

Creating Freedom

Join me on the journey from engineer to solopreneur. Learn how to build profitable SaaS products while keeping your technical edge.

    Proven strategies

    Learn the counterintuitive ways to find and validate SaaS ideas

    Technical insights

    From choosing tech stacks to building your MVP efficiently

    Founder mindset

    Transform from engineer to entrepreneur with practical steps