Django ORM: Choosing Between select_related and prefetch_related for N+1 Query Problems
Django's select_related and prefetch_related solve N+1 queries differently: JOINs for single-valued relationships, separate queries joined in Python for collections. A practical guide to choosing, combining, and verifying the fix with a worked article-listing example.
28 Sept 2025, 06:55 UTC

The N+1 Problem in Production
You're rendering a list of 50 articles. Each article needs its author, the author's profile, tags, and comments. Without optimization, Django executes 201 queries: one for articles, then 50 each for authors, profiles, tags, and comments. The page loads in seconds instead of milliseconds. This is the classic N+1 problem, and Django provides two tools to fix it: select_related and prefetch_related. They solve different relationship types and have distinct performance trade-offs.
select_related performs SQL JOINs for single-valued relationships—ForeignKey and OneToOne. It fetches everything in one query. For an article with an author who has a profile, select_related('author__profile') produces a single query joining three tables.
prefetch_related executes separate queries for each relationship and joins results in Python. It supports ManyToMany, reverse ForeignKey, and GenericRelation. For tags and comments on articles, it runs one query for all tags and one for all comments, then maps them to their parent articles in memory.
Attempting select_related on a many-valued path raises FieldError. Use prefetch_related for author.articles or article.tags.
A Worked Example: Article Listing
Consider these models:
class Author(models.Model):
name = models.CharField(max_length=100)
class Profile(models.Model):
author = models.OneToOneField(Author, on_delete=models.CASCADE)
bio = models.TextField()
class Tag(models.Model):
name = models.CharField(max_length=50)
class Article(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, on_delete=models.CASCADE)
tags = models.ManyToManyField(Tag)
class Comment(models.Model):
article = models.ForeignKey(Article, on_delete=models.CASCADE)
text = models.TextField()Naive queryset in a view:
articles = Article.objects.all()
for article in articles:
print(article.author.profile.bio) # N+1 for author + profile
for tag in article.tags.all():
print(tag.name)
for comment in article.comment_set.all():
print(comment.text)With 50 articles: ~201 queries.
Optimized queryset:
articles = Article.objects.select_related(
'author__profile'
).prefetch_related(
'tags',
'comment_set'
)Now: 4 queries total (articles + author/profile join, tags, comments). The select_related handles the single-valued chain; prefetch_related handles the collections.
Advanced: Custom Prefetch Querysets
Sometimes you need filtered or ordered prefetches. The Prefetch object (Django 2.0+) lets you supply a custom queryset and control the cache attribute name via to_attr:
from django.db.models import Prefetch
articles = Article.objects.select_related('author__profile').prefetch_related(
Prefetch('tags', queryset=Tag.objects.order_by('name')),
Prefetch(
'comment_set',
queryset=Comment.objects.order_by('-id'),
to_attr='recent_comments'
)
)
# In template: article.recent_comments (list) instead of article.comment_set.all()This avoids an extra query per article for ordered comments and lets you rename the cached result.
Trade-offs and Limitations
- JOIN bloat:
select_relatedacross many tables widens rows and can hit database join limits (MySQL 61-table limit, PostgreSQLmax_joinsplanner setting). - Memory overhead:
prefetch_relatedloads all related objects into memory at once. For thousands of comments per article, this risks running out of memory. Paginate the parent queryset and useiterator(chunk_size=100)to stream results. - Ordering caveat:
prefetch_relatedignoresorder_by()applied to the prefetched relation unless you wrap it in aPrefetchobject with an ordered queryset. - Async views (Django 4.1+): ORM evaluation remains synchronous. Wrap ORM calls in
sync_to_async; check your Django version for native async queryset support before relying on it.
Verifying the Fix
In development with DEBUG=True, inspect executed queries:
from django.db import connection
articles = list(Article.objects.select_related('author__profile').prefetch_related('tags', 'comment_set'))
for q in connection.queries:
print(q['sql'][:200], '...', q['time'])You should see four queries rather than hundreds. Or install django-debug-toolbar and check the SQL panel for duplicate queries and N+1 patterns triggered from templates. You can also inspect the generated SQL before evaluation with str(queryset.query).
Actionable Checklist
- Identify the relationship types: single-valued (
ForeignKey,OneToOne) →select_related; many-valued (ManyToMany, reverse FK) →prefetch_related. - Combine both in one queryset for parent chains and child collections.
- Use
Prefetchobjects when you need custom ordering, filtering, or a different cache attribute name. - Add
only()ordefer()to limit column width ifselect_relatedpulls too many fields. - For large collections, paginate the parent queryset and evaluate with
iterator(chunk_size=...). - Verify query count in development with
connection.queriesor debug toolbar before deploying.
Start with the combination pattern—select_related for the 'up' chain, prefetch_related for the 'down' collections—and measure. The 201-to-4 query reduction is typical for real-world article listings, and the pattern scales to most content-heavy views.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.