The N+1 query problem doesn't always look like a loop. It hides in list comprehensions, templates, aggregations, and serializers — code that reads clean, passes review, and hammers your database once per row. Here are the five patterns that slip through every Django team.
This is the foundational Django ORM performance mistake. Both fix N+1 queries — but they generate completely different SQL, and using the wrong one can actually make things worse.
select_related uses a SQL JOIN and works for single-valued relationships (ForeignKey, OneToOne). One query total. prefetch_related runs a separate query per relationship and does the joining in Python — use it for ManyToMany or reverse ForeignKey.
def get_posts(request):
# 1 query for posts
posts = Post.objects.all()[:50]
return render(request, 'posts.html', {
'posts': posts
})
# In the template:
# {% for post in posts %}
# {{ post.author.name }} <-- query per post
# {% endfor %}
def get_posts(request):
# 1 query, author data joined
posts = Post.objects.select_related(
'author'
)[:50]
return render(request, 'posts.html', {
'posts': posts
})
# Template accesses post.author.name
# from the already-fetched JOIN result
Use select_related for ForeignKey/OneToOne (single object per row). Use prefetch_related for ManyToMany or reverse FK (multiple objects per row). Using select_related on a ManyToMany raises an error — using prefetch_related on a ForeignKey works but generates 2 queries instead of 1. Get the choice wrong and you're either broken or slower than you need to be.
def get_articles(request):
# select_related on M2M = ValueError
articles = Article.objects.select_related(
'tags' # raises exception
)
return render(request, 'articles.html', {
'articles': articles
})
def get_articles(request):
# 2 queries: articles + tags
# Python-side join
articles = Article.objects.prefetch_related(
'tags'
)
return render(request, 'articles.html', {
'articles': articles
})
The difference isn't obvious from the queryset call alone. You need to know the model's relationship type. In a code review, reviewers see .prefetch_related('author') on a ForeignKey and think "that's fine, it prefetches" — not "that generates an extra query when select_related would be free." The SQL impact is invisible without running django-debug-toolbar or logging queries.
This is the N+1 in disguise. A list comprehension looks like a single data transformation — but if it accesses a related field on each object, it fires a query per iteration. The comprehension syntax hides the loop.
def get_team_stats(org_id):
repos = Repository.objects.filter(
org_id=org_id
)
# This fires 1 query per repo
# to fetch repo.owner
return [
{
'name': repo.name,
'owner': repo.owner.username,
'stars': repo.star_count,
}
for repo in repos
]
def get_team_stats(org_id):
repos = Repository.objects.filter(
org_id=org_id
).select_related('owner')
# owner data already loaded via JOIN
return [
{
'name': repo.name,
'owner': repo.owner.username,
'stars': repo.star_count,
}
for repo in repos
]
The SQL tells the story clearly. For 100 repos without select_related:
-- What Django actually executes (101 queries): SELECT * FROM repositories WHERE org_id = 42; SELECT * FROM users WHERE id = 1; -- repo 1's owner SELECT * FROM users WHERE id = 7; -- repo 2's owner SELECT * FROM users WHERE id = 3; -- repo 3's owner -- ... 97 more identical queries ...
The list comprehension reads as a pure transformation — it doesn't look like a database loop. Reviewers who know to look for for obj in queryset: obj.related often don't apply the same scrutiny to comprehensions. Django's lazy loading is the real culprit: related fields resolve on first access, silently, wherever that access happens.
The most invisible Django N+1. The view looks clean — no loops, no related access. The query storm is in the template, where Django's lazy loading fires silently on every {{ post.author.name }} in a for loop.
# views.py — looks clean
def post_list(request):
posts = Post.objects.filter(
published=True
).order_by('-created_at')[:20]
return render(request, 'posts.html', {
'posts': posts
})
# posts.html — the real problem:
# {% for post in posts %}
# <h2>{{ post.title }}</h2>
# <p>by {{ post.author.name }}</p>
# <p>in {{ post.category.name }}</p>
# {% endfor %}
# views.py — fix at the queryset level
def post_list(request):
posts = Post.objects.filter(
published=True
).select_related(
'author',
'category'
).order_by('-created_at')[:20]
return render(request, 'posts.html', {
'posts': posts
})
# Template unchanged — but now
# author + category data is already loaded
# No queries fire in the template loop
The view file and template file are reviewed separately. A reviewer looking at views.py sees a queryset returning posts — nothing suspicious. The N+1 is in posts.html, which may not even be in the same PR. Template loops accessing related fields are standard Django idiom — they don't look like bugs. You need to cross-reference view + template to spot it, which reviewers rarely do under time pressure.
This also applies to custom template tags and inclusion_tag functions. If a template tag accesses related fields on objects passed in from the parent context, it triggers the same lazy-load chain — and it's even harder to spot because the access is buried inside a Python function called from template syntax.
When you need a count or sum across related objects, the instinct is to fetch the related objects and aggregate in Python. This loads N rows from the database to compute a number that the database could return in a single query.
def get_org_summary(org_id):
teams = Team.objects.filter(org_id=org_id)
summary = []
for team in teams:
# Fetches ALL members just to count them
member_count = len(team.members.all())
# Fetches ALL reviews to sum scores
total_score = sum(
r.score for r in team.reviews.all()
)
summary.append({
'team': team.name,
'members': member_count,
'score': total_score,
})
return summary
from django.db.models import Count, Sum
def get_org_summary(org_id):
# Single annotated query
teams = Team.objects.filter(
org_id=org_id
).annotate(
member_count=Count('members'),
total_score=Sum('reviews__score')
).values(
'name', 'member_count', 'total_score'
)
return [
{
'team': t['name'],
'members': t['member_count'],
'score': t['total_score'] or 0,
}
for t in teams
]
The bad version is natural Python. len(queryset.all()) and a generator sum are idiomatic Python code — they just happen to be catastrophically wrong for database access. Reviewers see Python, not SQL. For a team of 50 members with 200 reviews, the bad version fires 101 queries; the good version fires 1. The performance difference is invisible in tests with fixtures that have 3 members.
The same pattern applies to .count() vs len(). Post.objects.filter(...).count() runs a SELECT COUNT(*). len(Post.objects.filter(...)) fetches every row and counts in Python. One number, wildly different costs at scale.
Django REST Framework serializers are N+1 machines by default. Every SerializerMethodField or nested serializer that accesses a related object triggers a lazy load per row — and because it's inside a serializer class, it doesn't look like a loop at all.
class PostSerializer(serializers.ModelSerializer):
author_name = serializers.SerializerMethodField()
comment_count = serializers.SerializerMethodField()
def get_author_name(self, obj):
# Lazy loads author per post
return obj.author.full_name
def get_comment_count(self, obj):
# Fetches ALL comments just to count
return obj.comments.count()
class Meta:
model = Post
fields = ['id', 'title',
'author_name', 'comment_count']
# View — N queries hidden in serializer:
class PostListView(generics.ListAPIView):
queryset = Post.objects.all()
serializer_class = PostSerializer
from django.db.models import Count
class PostSerializer(serializers.ModelSerializer):
author_name = serializers.SerializerMethodField()
comment_count = serializers.IntegerField(
source='comment_count' # from annotation
)
def get_author_name(self, obj):
# Already loaded via select_related
return obj.author.full_name
class Meta:
model = Post
fields = ['id', 'title',
'author_name', 'comment_count']
# View does the heavy lifting:
class PostListView(generics.ListAPIView):
serializer_class = PostSerializer
def get_queryset(self):
return Post.objects.select_related(
'author'
).annotate(
comment_count=Count('comments')
)
The serializer is reviewed as a data-shaping class, not as a query executor. get_author_name looks like it's just formatting data — the database call is two hops away (obj → lazy load → .full_name). DRF's design lets you write what looks like clean object access that's secretly firing a query per row. The fix lives in the view's get_queryset, not the serializer — so the bad pattern can survive even a thorough serializer review.
The DRF docs recommend the select_related/prefetch_related fix, but it requires knowing the serializer's field access pattern when writing the view. Third-party packages like django-debug-toolbar and nplusone can catch these at runtime in development — but they don't stop the pattern from shipping to production if tests never exercise the real database.
ORM traversal detection, aggregation shortcuts, DRF serializer analysis. Every Django PR reviewed in 30 seconds, before it merges.
Install Free on GitHub 5 PRs/month free · No credit card · Uninstall in one click