There's a specific kind of bug that doesn't look like a bug at all. Your code works. Your tests pass. Everything returns the right data. Then you deploy, real users show up, and suddenly a page that loaded instantly in development takes three seconds in production or worse, times out entirely.
Nine times out of ten, when this happens on a Django project, the culprit is the N+1 query problem. It's one of the most common performance issues in any ORM-based application, and it's especially easy to introduce without noticing, because the code that causes it often looks perfectly reasonable.
I ran into this repeatedly while working on a platform with tens of thousands of users generating posts, comments, and messages, the kind of read-heavy, relationship-heavy data model where N+1 issues hide easily and get expensive fast once real traffic shows up.
What N+1 actually means
The name describes exactly what happens: 1 query to fetch a list of objects, then N additional queries, one per object to fetch related data. If you're rendering a list of 100 blog posts and looking up each post's author separately, that's 1 query for the posts and 100 more for the authors. 101 queries to render one page.
Here's the classic example:
# views.py
def post_list(request):
posts = Post.objects.all()
return render(request, "posts.html", {"posts": posts})
<!-- posts.html -->
{% for post in posts %}
<h2>{{ post.title }}</h2>
<p>by {{ post.author.name }}</p>
{% endfor %}
This looks completely normal. Nothing here is "wrong" in the sense of producing incorrect output. But every time the template accesses post.author, Django fires a fresh query to fetch that author from the database, because author is a foreign key that wasn't loaded up front. With 100 posts, that's 100 extra queries invisible in the code, very visible in your database load and response time.
Spotting it before your users do
The scary part of N+1 is that it's silent in development. With 10 posts and a local SQLite database on your laptop, 11 queries execute in milliseconds and nobody notices. The problem only becomes visible at scale exactly when it's most expensive to discover.
A few ways to catch it early:
Django Debug Toolbar shows you the exact number of queries executed per page, and will explicitly flag duplicate/similar queries, which is usually the fingerprint of an N+1 pattern.
django-silk or query logging useful in staging environments where Debug Toolbar isn't installed, to log query counts per request and catch regressions before they hit production.
A simple habit: any time you loop over a queryset and access a related object or reverse relation inside that loop, stop and ask whether that relation is being loaded eagerly. This single habit catches the majority of N+1 bugs before they're even written.
Fixing it: select_related for forward relations
For foreign key and one-to-one relationships, use select_related. It performs a SQL JOIN and pulls the related object into the same query, rather than firing a separate query per row.
posts = Post.objects.select_related("author").all()
Now post.author is already loaded when the template accesses it, no extra query per post, just the one JOIN-based query up front. This works for "forward" relationships, where the model you're querying holds the foreign key.
Fixing it: prefetch_related for reverse and many-to-many relations
For reverse foreign keys and many-to-many relationships, select_related's JOIN approach doesn't work the same way; instead, use prefetch_related, which runs a second, separate query for the related objects and joins them in Python rather than in SQL.
posts = Post.objects.prefetch_related("comments").all()
{% for post in posts %}
<h2>{{ post.title }}</h2>
<p>{{ post.comments.count }} comments</p>
{% endfor %}
This runs 2 queries total, regardless of how many posts there are: one for the posts, one for all the related comments, matched up in Python. Compare that to N+1 queries without it.
Combining both, and going deeper with Prefetch
Real-world querysets often need both at once:
posts = Post.objects.select_related("author").prefetch_related("comments", "tags")
For more complex cases — say, you only want to prefetch approved comments, not all of them — use the Prefetch object to customize the prefetch queryset itself:
from django.db.models import Prefetch
posts = Post.objects.prefetch_related(
Prefetch("comments", queryset=Comment.objects.filter(approved=True))
)
This keeps the query count low while still letting you filter, order, or annotate the related data exactly as needed, useful in a comments/messaging-heavy system where you often only want a subset of related rows, not all of them.
Watch for N+1 hiding in serializers, not just templates
If you're building an API with Django REST Framework, the same problem shows up in serializers, and it's arguably easier to miss because there's no template to visually inspect.
class PostSerializer(serializers.ModelSerializer):
author_name = serializers.CharField(source="author.name")
class Meta:
model = Post
fields = ["title", "author_name"]
If the view serving this serializer doesn't call select_related("author") on the queryset, every serialized post triggers a separate query for author.name the exact same N+1 pattern, just one layer removed from where it's easy to spot. This is especially worth checking in nested serializers, where a list endpoint returning related objects (comments, tags, likes) is a very common place for N+1 to hide in an API-first project.
A quick way to verify you've actually fixed it
Don't just assume select_related/prefetch_related fixed things verify with Django's query logging in a shell:
from django.db import connection, reset_queries
from django.conf import settings
settings.DEBUG = True # only in a dev/test shell, never production
reset_queries()
posts = list(Post.objects.select_related("author").prefetch_related("comments"))
for post in posts:
_ = post.author.name
_ = post.comments.count()
print(len(connection.queries)) # should be a small, fixed number, not proportional to len(posts)
If the query count scales with the number of posts, something's still triggering N+1 usually a relation you forgot to include in select_related/prefetch_related, or a nested relation two levels deep that needs its own prefetch.
A short checklist
- Any loop over a queryset that accesses a related object or reverse relation is checked for eager loading
- Forward foreign key / one-to-one access uses
select_related - Reverse foreign key / many-to-many access uses
prefetch_related - Complex prefetch filtering uses
Prefetchobjects rather than filtering in Python after the fact - DRF serializers are checked for the same pattern, not just templates especially nested serializers
- Query counts are verified with Debug Toolbar or
connection.queriesbefore shipping, not just assumed fixed
N+1 queries rarely show up as an obvious bug, they show up as "the app feels slow" or "the database CPU spiked" days or weeks after a feature shipped. Catching the pattern while writing the code, rather than debugging it under production load, is a lot cheaper for the database and for you.
Top comments (0)