The N+1 problem: how a harmless-looking loop quietly fires one database query per row, and the two methods that collapse it back to one.
The page was a simple list. Show every book, with its author's name next to it.
books = Book.objects.all()
{% for book in books %}
<li>{{ book.title }} by {{ book.author.name }}</li>
{% endfor %}
On my laptop, with twelve books in the database, it loaded instantly. It stayed fast through code review. It shipped.
Then production, with a real catalogue, got slow. Not broken. Slow. The page that rendered in 30 milliseconds locally was taking four seconds live, and the database was lighting up while it did.
I turned on query logging and felt my stomach drop. That one page, the simple list, was running over five thousand database queries to render. One to get the books. And then one more for every single book, to go fetch its author.
I had written what looked like one query. I had actually written one, plus one per row. This is the N+1 problem, and it is probably the single most common performance bug in Django. Here is exactly how the loop betrays you, and the two methods that fix it.
Where the Thousands of Queries Come From
The trap hides in a line that does not look like a query at all.
books = Book.objects.all() # looks like the only database call
That line fetches the books. One query. So far so good.
The damage happens later, in the template, on this piece:
{{ book.author.name }}
book.author is a foreign key. When you first access it, the author is not in memory yet, so Django quietly runs a query to go get that author from the database. One query, right there, triggered just by reading the attribute.
Now put that inside a loop over every book. For each book, the template reads book.author, and each read fires its own query. One hundred books means one hundred author queries. Five thousand books means five thousand author queries.
So the real cost is:
1 query to fetch the books, plus N queries, one per book, to fetch each author.
That is the "N+1." One query for the parents, N queries for the related objects, and the N is however many rows you have.
The line that triggers it, book.author, does not look like database access. It looks like reading a field on an object you already have. That is exactly why it is so easy to write and so hard to spot.
Why It Is Invisible Until It Is Catastrophic
This bug has the signature of every dangerous one. It scales with your data, and your development database has almost none.
On my laptop I had twelve books. Twelve books means thirteen queries, which run in a couple of milliseconds. Nothing to notice. The page was fast, the tests passed, nobody saw a problem.
Production had thousands of rows. The exact same code now meant thousands of queries, each a separate round trip to the database, and the page crawled.
That is the cruel part: the code did not change between fast and slow. The row count did. N+1 is invisible precisely when you are testing, because you test with a handful of rows, and it only becomes catastrophic when real data arrives. The bug was always there. It just needed enough rows to show its teeth, and you never have enough rows on your laptop.
This is also why it survives review. A reviewer reads book.author.name and sees a field access, not a query. There is nothing visibly wrong with the code. The wrongness is in how it multiplies, and multiplication does not show up in a diff.
The Fix for Foreign Keys: select_related
When the related object is reached through a forward foreign key or a one-to-one (each book has exactly one author), the fix is select_related.
books = Book.objects.select_related("author")
This tells Django to fetch the books and their authors together, in a single query, using a SQL JOIN. Instead of fetching books and then running off to get each author one at a time, the database returns each book with its author's columns already attached.
The result:
N+1 queries collapse into one.
Now the template's book.author.name touches data that is already in memory, loaded by that single joined query. No per-row database trip. The page that ran five thousand queries runs one.
The way to read it: select_related does the joining in the database, in one query, and it is for single-valued relationships, where each row points to exactly one related object (ForeignKey, OneToOne). That "one related object per row" is what lets it ride along in a single JOIN.
The Fix for the Many Side: prefetch_related
A JOIN works when each book has one author. But what about the other direction, where one object has many related rows? An author with many books, a post with many tags, an order with many line items. You cannot pull "many" into a single joined row cleanly, so Django gives you a different tool: prefetch_related.
authors = Author.objects.prefetch_related("books")
This runs a separate second query to fetch all the related objects at once, then joins them to their parents in Python, in memory. So fetching a list of authors and all their books costs:
2 queries total. One for the authors, one for all their books.
Not one query per author. Two queries for the whole thing, regardless of how many authors you have.
It nests too: prefetch_related("books", "books__publisher") adds one more query for the publishers, and so on: one additional query per relationship level, not per row.
The way to read it: prefetch_related does the joining in Python across a small number of queries, and it is for multi-valued relationships: the many-to-many and reverse-foreign-key cases where a single JOIN would not work.
Which One to Reach For
The choice comes down to one question: how many related objects does each row point to?
| Each row points to... | Relationship types | Use | How it works |
|---|---|---|---|
| One related object | Forward ForeignKey, OneToOne
|
select_related |
One query, a SQL JOIN |
| Many related objects | Many-to-many, reverse ForeignKey
|
prefetch_related |
A separate query per relationship, joined in Python |
A useful shortcut:
- If you access the relationship as a single object (
book.author), you almost certainly wantselect_related. - If you access it as a collection you loop over (
author.books.all()), you wantprefetch_related.
And you can combine them freely on one queryset when a page touches both kinds:
Book.objects.select_related("author").prefetch_related("tags")
One JOIN for the single author, one extra query for the many tags. A page that might have run thousands of queries now runs a small, fixed handful, no matter how many books, authors, or tags exist.
The Rule I Take Away
Compressed to one line:
Accessing a related object inside a loop fires one query per row, turning one intended query into N+1. Fetch the relationships up front:
select_relatedfor single-valued relationships (one JOIN),prefetch_relatedfor multi-valued ones (a second query joined in Python).
The deeper lesson, the one that outlasts this specific framework, is that in an ORM, reading an attribute can be a database call, and the ones that fire inside loops are the ones that hurt.
The ORM is wonderful because it hides the database behind ordinary-looking object access. That same hiding is what makes N+1 so easy to write: book.author looks free, costs a query, and you put it in a loop without a second thought.
The fix is always the same shape: load what the loop will need before the loop, in a bounded number of queries, instead of letting the loop pull it one row at a time.
And the usual sting applies. This never shows up when you test, because you test with a handful of rows where N+1 is indistinguishable from one query. It only bites once real data arrives, which is to say in production, at the worst possible time, on the page you were sure was simple.
How to Catch It Before It Catches You
Keep these four habits:
- Count your queries, do not eyeball your code. Install the Django Debug Toolbar (or log queries) and watch the query count for each page. N+1 is invisible in the code and glaringly obvious in a query count that scales with your rows. This single tool catches almost every instance.
-
Suspect every related access inside a loop.
book.author,order.customer,item.productinside aforloop, in a template or a serializer, is a query per iteration. The moment you loop and touch a relationship, ask whether you prefetched it. -
Match the method to the relationship. Single object per row (
book.author) →select_related. Collection per row (author.books.all()) →prefetch_related. Combine both on one queryset when a page needs both. - Test with realistic data volumes. Seed your development or test database with thousands of rows, not twelve. N+1 hides at small scale by definition; the only way to feel it before production does is to give it enough rows to multiply.
I added .select_related("author") to that one queryset, reloaded, and watched the query count fall from over five thousand to one. The page dropped back under a tenth of a second. The code in the template never changed. I had simply loaded the authors before the loop, instead of letting the loop fetch them five thousand times.
The 30-Second Takeaway
-
Accessing a related object (
book.author) inside a loop triggers a separate query per row, because the ORM lazily fetches the relationship on first access. One list query plus N per-row queries is the N+1 problem. - It is invisible in development (a handful of rows means a handful of queries) and catastrophic in production (thousands of rows means thousands of queries). The code never changes; the row count does.
-
select_relatedfixes it for single-valued relationships (forwardForeignKey,OneToOne): it does a SQL JOIN and fetches everything in one query. Use it when you access the relation as one object. -
prefetch_relatedfixes it for multi-valued relationships (many-to-many, reverseForeignKey): it runs a separate query and joins in Python, costing a small fixed number of queries (e.g. 2), not one per row. Use it when you loop over a collection. -
Choosing: one related object per row →
select_related; many per row →prefetch_related. Combine both on one queryset when needed. - Catch it with a query counter (Django Debug Toolbar), suspect every related access inside a loop, and test with realistic data volumes, because N+1 only reveals itself at scale.
Your Turn
Install the Django Debug Toolbar and open your busiest list page. If the query count climbs with the number of rows on the page, you have an N+1. Add select_related or prefetch_related to the queryset and watch it collapse to a handful.
What's the worst query count you've ever found on a single page? Tell me in the comments 👇
If this was useful, follow me here for more stories from the backend trenches, and subscribe to The Backend Tech Lead on Substack for weekly blueprints.
Top comments (0)