The Django ORM’s biggest strength is also its biggest trap: order.customer.name reads like a free attribute access, but behind that dot is a decision about whether Django already has that data in memory or is about to fire a brand-new SQL query to fetch it. Most of the time, in a template loop or a serializer, it fires that query once per row — and because each individual query is fast, nobody notices until the page that took 40ms locally takes four seconds against production data with a thousand rows instead of ten.
That’s the specific shape of an N+1 problem: one query to fetch a list, then N more queries — one per item — to fetch a related object for each row. It’s not a bug in Django; it’s the natural consequence of lazy relationship loading combined with code that never explicitly says “and also load the related rows in bulk.” The fix isn’t guesswork or blanket over-fetching — it’s identifying exactly which relationship is triggering the repeated queries and picking the one tool, out of a small handful, that matches that relationship’s actual shape.
You’ll learn:
- How to see N+1 queries in a real request trace, not just suspect them
- Why lazy relationship access is what causes N+1 in the first place
- When
select_relatedis the right fix, and why it doesn’t work for every relationship type - When
prefetch_relatedis the right fix, and what it actually does differently under the hood - How
Prefetchobjects handle filtered and ordered related querysets - When neither tool is right and raw aggregation is the better answer
- How to make N+1 regressions fail CI before they reach production
Table of Contents
- What N+1 Actually Looks Like
- Seeing It: Tools for Catching N+1 in the Wild
- Why Lazy Loading Causes This
- select_related: For Forward and One-to-One Relationships
- prefetch_related: For Reverse and Many-to-Many Relationships
- Prefetch Objects: Filtering and Ordering Related Data
- When Neither Is Right: Aggregation Instead of Iteration
- Catching Regressions in CI
- Common Pitfalls
What N+1 Actually Looks Like
Take a page listing recent orders with each customer’s name:
orders = Order.objects.filter(status="pending")[:50]
for order in orders:
print(order.customer.name)
This looks like 51 lines of harmless code. It’s 51 queries: one SELECT for the initial orders queryset, then one additional SELECT ... WHERE id = ? for every single order.customer access, because customer is a foreign key Django loads lazily on first access, not eagerly when the order was fetched. Fifty rows means fifty extra round trips to the database, each one paying full connection and network overhead for a single-row lookup that could have been one JOIN.
This is the exact loop-and-lookup pattern that shows up as a nested loop with a high iteration count in a Postgres plan — see the loops section of the EXPLAIN ANALYZE guide for the same problem viewed from the database side rather than the ORM side. The two are the same bug wearing different clothes: a cheap-looking operation repeated once per row instead of batched once for the whole set.
Seeing It: Tools for Catching N+1 in the Wild
Guessing at N+1 from code review misses real ones and flags false positives constantly — the only reliable way to find them is to look at the actual query count for a real request.
Django Debug Toolbar is the fastest local option — it panel-counts every query executed per request, shows duplicate queries side by side, and will visibly flag “similar queries” when the same SELECT ... WHERE id = ? shape repeats across a response.
django.test.utils.CaptureQueriesContext does the same thing programmatically, which matters because it’s usable in tests, not just an interactive browser session:
from django.test.utils import CaptureQueriesContext
from django.db import connection
with CaptureQueriesContext(connection) as ctx:
list(Order.objects.filter(status="pending")[:50])
for order in Order.objects.filter(status="pending")[:50]:
_ = order.customer.name
print(len(ctx.captured_queries)) # 51, not 2
django-silk and APM tools like Sentry’s performance monitoring or Datadog’s APM go further in production — they attribute query counts and cumulative time to specific views and even specific lines, which is how you catch N+1 patterns that only appear under real data volume and real relationship fan-out, not the small fixture data used locally.
The signal to look for in any of these tools is the same: a nearly identical query, differing only in a single WHERE-clause parameter, repeated dozens or hundreds of times in one request. That repetition, not the total query count alone, is the real fingerprint of N+1.
Why Lazy Loading Causes This
Django’s ORM is lazy by design — a queryset doesn’t hit the database until it’s iterated, and a related-object descriptor doesn’t hit the database until it’s accessed. This is a deliberate, generally good default: it means Order.objects.filter(...) can be built up across multiple lines, passed around, and further filtered without firing a query prematurely.
The cost of that laziness is that Django has no way to know, at the point where you write orders = Order.objects.filter(...), that your very next line is going to touch .customer on every single row. Each order.customer access is a completely independent decision from Django’s point of view — it doesn’t remember that it just answered the identical kind of question forty-nine rows ago. select_related and prefetch_related exist specifically to tell Django, up front, “you already know I’m going to need this related data for every row — go get it all now, in one or two queries, instead of waiting to be asked one row at a time.”
It’s worth being precise about what is cached, because it’s a common source of confusion. Once a queryset has been evaluated (iterated once, sliced, or forced with list()), its results are cached on that queryset instance — iterating it a second time doesn’t refire the query. But that cache lives on the queryset object itself, not on the model instances it produced, and it doesn’t extend to related-object lookups at all. order.customer the first time and order.customer the second time on the same order object does hit Django’s per-instance relation cache and only queries once — but a fresh call to Order.objects.filter(...) a moment later starts from zero, with no memory of anything the previous queryset already fetched. This is exactly why N+1 shows up specifically in loops: each iteration produces a distinct model instance with its own empty relation cache, so the “already asked this” savings never accumulate across rows.
Async Views and Query Counting
Django’s async views (async def get(self, request), available since 4.1) don’t change any of the mechanics above — select_related and prefetch_related work identically, and sync_to_async-wrapped ORM calls still batch the same way. What changes is how easy an N+1 problem is to miss: under asyncio, concurrent requests interleave on the same worker, so a slow, query-heavy view doesn’t block the whole process the way a synchronous one would, and a query-count regression can hide behind seemingly fine wall-clock latency until concurrency climbs. The debugging approach is unchanged — CaptureQueriesContext and assertNumQueries work the same under async def test clients — but treat async as a reason to test query counts more deliberately, not a reason it matters less.
select_related for Forward and One-to-One Relationships
select_related works by generating a SQL JOIN and pulling the related row’s columns into the same query as the parent — one round trip, one result set, related objects already populated when you access them.
orders = Order.objects.filter(status="pending").select_related("customer")
for order in orders:
print(order.customer.name) # no extra query — already joined
This only works for relationships where each row has exactly one related row to join against: ForeignKey and OneToOneField, followed in the “forward” direction (from Order to its Customer, not the reverse). You can chain across multiple hops in a single call:
Order.objects.select_related("customer__account__billing_address")
Each additional hop adds columns to the same joined query rather than a new query, so this stays at one round trip no matter how deep the chain goes — the tradeoff is a wider result set per row, which matters if the joined tables have many columns you don’t actually need. select_related cannot help with reverse foreign keys or many-to-many relationships, because a JOIN that could return multiple related rows per parent row breaks the one-row-per-parent shape a JOIN naturally produces — that’s exactly the case prefetch_related exists for.
prefetch_related for Reverse and Many-to-Many Relationships
prefetch_related takes a different strategy entirely: instead of one JOINed query, it runs a second, separate query that fetches all related rows for the entire batch at once, then stitches them onto the right parent objects in Python.
customers = Customer.objects.filter(active=True).prefetch_related("orders")
for customer in customers:
for order in customer.orders.all(): # no extra query per customer
print(order.total)
That’s two queries total, regardless of whether there are 10 customers or 10,000: one SELECT * FROM customers WHERE active, and one SELECT * FROM orders WHERE customer_id IN (...) covering every customer in the first result set. Django then groups the second result set by customer_id in Python and attaches each order to its matching customer, so customer.orders.all() inside the loop never touches the database again.
This is the right (and only) tool for reverse foreign keys (customer.orders, the “many” side looking back at “one”), many-to-many fields, and any relationship where a single parent row could have multiple matching related rows — a JOIN can’t represent that without duplicating parent rows, so prefetch_related’s separate-query-plus-Python-merge approach is structurally necessary, not just an alternative style. Choosing between select_related vs prefetch_related comes down entirely to relationship cardinality: one related row per parent uses select_related; potentially many related rows per parent uses prefetch_related.
select_related | prefetch_related | |
|---|---|---|
| Relationship shape | one related row per parent | zero, one, or many related rows per parent |
| Applies to | ForeignKey, OneToOneField (forward) | reverse FK, ManyToManyField, GenericRelation |
| Mechanism | one query, SQL JOIN | two (or more) queries, merged in Python |
| Query count | always 1 for the whole chain | 1 + 1 per prefetched relationship |
| Can filter/order the related set | no (join returns full parent columns) | yes, via Prefetch objects |
That last row is worth internalizing on its own: select_related cannot narrow which related row comes back, because a JOIN either matches or it doesn’t — there’s no concept of “give me only the most recent match” inside a plain join. Any time a related lookup needs its own filter or ordering, you’re in prefetch_related territory even if the relationship is nominally one row per parent, because a Prefetch object is the only mechanism that accepts a customized queryset.
Prefetch Objects: Filtering and Ordering Related Data
prefetch_related("orders") fetches all related orders. Often you only want a filtered or ordered subset — only pending orders, or only the five most recent — and a plain string argument can’t express that. Prefetch objects can:
from django.db.models import Prefetch
recent_pending = Prefetch(
"orders",
queryset=Order.objects.filter(status="pending").order_by("-created_at"),
to_attr="recent_pending_orders",
)
customers = Customer.objects.prefetch_related(recent_pending)
for customer in customers:
for order in customer.recent_pending_orders: # already filtered, already ordered
print(order.total)
The queryset argument lets you filter, order, or even select_related further within the prefetch itself — a Prefetch can nest a select_related inside it to cover a one-to-one relationship hanging off a many-to-many one, still in exactly two total queries. The to_attr argument stores the filtered result under a new attribute name rather than overwriting the default manager, which matters because reusing the same customer object elsewhere in the request with an unfiltered .orders.all() call would otherwise silently trigger a brand-new, unprefetched query.
When Neither Is Right: Aggregation Instead of Iteration
Sometimes the actual goal isn’t “give me every related object for every row” — it’s a single number or a small set of numbers per row, like an order count or a total. Fetching full related objects with prefetch_related just to len() or sum() them in Python is wasted work; the database can compute that aggregate directly, in one query, without ever materializing the individual related rows into Python objects at all.
from django.db.models import Count, Sum
customers = Customer.objects.annotate(
order_count=Count("orders"),
lifetime_total=Sum("orders__total"),
)
for customer in customers:
print(customer.order_count, customer.lifetime_total) # no related objects fetched at all
This is a genuinely different fix from the previous two, and it’s the right one whenever the end goal is a number, not the related rows themselves. annotate() pushes the aggregation into the database — the same place GROUP BY and COUNT() already do this work efficiently — instead of pulling every related row across the network just to collapse it back down in Python.
Catching Regressions in CI
The best fix for N+1 isn’t a one-time cleanup pass — it’s making a regression fail a test before it ships. django-test-plus’s assertNumQueries, or Django’s own built-in version, pins the expected query count for a view or a code path:
from django.test.utils import CaptureQueriesContext
from django.db import connection
def test_order_list_view_query_count(self):
with self.assertNumQueries(2):
response = self.client.get("/orders/")
self.assertEqual(response.status_code, 200)
A test like this fails loudly the moment someone adds a new order.customer.name access to a template or serializer without also updating the corresponding queryset’s select_related/prefetch_related chain — catching the regression in a two-second CI run instead of in a production APM dashboard weeks later. Pin the count on the highest-traffic list and detail views first; that’s where N+1 fan-out does the most damage per request, and where a fixed test pays for itself fastest.
Common Pitfalls
Mistake: reaching for select_related on a reverse foreign key. Django will raise a FieldError — select_related genuinely cannot follow a relationship where multiple rows could match. Solution: use prefetch_related for anything on the “many” side, reflexively.
Mistake: prefetching a relationship, then filtering it again with .filter() inside the loop. customer.orders.filter(status="pending") inside a loop after prefetch_related("orders") triggers a brand-new query per row, because a fresh .filter() call is a different queryset than the cached prefetch. Solution: apply the filter inside a Prefetch object’s queryset argument up front, and use to_attr so there’s no way to accidentally fall back to an unfiltered, unprefetched call.
Mistake: over-fetching with select_related across very wide join chains. Chaining select_related five hops deep pulls every column from every table in the chain into every row, even when only one field from the deepest table is actually used. Solution: use .only() alongside select_related to restrict the joined columns, or reconsider whether the deep join is even needed for that view.
Mistake: assuming a low total query count means no N+1 problem. Ten queries for ten rows still passes a naive “queries under some threshold” check while remaining a genuine N+1 pattern that will linearly worsen as the table grows. Solution: look for the repeated-query-shape signal specifically, not just a raw count, and re-test with realistic row counts, not fixture-sized ones.
Mistake: fixing N+1 in the view but not in the serializer. Django REST Framework serializers with nested relations re-trigger the exact same lazy-loading problem independently of whatever the view’s queryset already did — a CustomerSerializer with a nested OrderSerializer(many=True) will happily fire one query per customer if the view’s queryset never called prefetch_related("orders"), regardless of how carefully the view itself was written. Solution: apply select_related/prefetch_related to the queryset the serializer actually consumes (usually in the ViewSet’s get_queryset()), and verify with assertNumQueries against the serialized response, not just the raw queryset — a serializer can introduce its own lazy access points that a queryset-only test would never catch.
Wrapping Up
N+1 queries aren’t a Django bug to work around — they’re the predictable cost of lazy relationship loading meeting code that never tells the ORM “batch this.” The fix is never “add prefetch_related everywhere and hope” — it’s identifying the relationship’s actual cardinality (one row or many), matching it to select_related or prefetch_related accordingly, reaching for Prefetch objects when the related set needs filtering or ordering, and recognizing when the real goal was an aggregate number that the database should compute directly instead of an iteration Python has to do by hand.
The underlying database still has to execute whatever query count your ORM code produces, so once the query count itself is right, it’s worth reading the resulting plan too — a select_related join is only as fast as the index backing it, which is where the EXPLAIN ANALYZE guide picks up from here.
Have you checked your highest-traffic view’s query count against its actual production row counts, or only against your local fixtures?
