How to Detect and Fix N+1 Query Problems in Django
How do I detect and fix N+1 query problems in Django?
TL;DR
- Bottom line: An N+1 query happens when your code executes 1 query to fetch N objects,
then N additional queries to fetch related data for each — resulting in N+1 total queries instead of 2.
In Django, this occurs because the ORM lazy-loads related objects by default. Fix with
select_related()(ForeignKey/OneToOne — SQL JOIN) orprefetch_related()(ManyToMany/reverse FK — separate query + Python join). - Key tool/command:
django-debug-toolbarshows all SQL queries per request with timing. For automated detection:nplusonelibrary logs warnings when N+1 patterns are detected. For zero-config fix:django-auto-prefetchautomatically prefetches ForeignKey values as needed. For tests:self.assertNumQueries(N). - Watch out for: Serializers (DRF) and templates that access related fields — they
trigger lazy loads invisibly. A template loop like
{% for book in books %}{{ book.author.name }}{% endfor %}fires N author queries. Also: calling.filter()on a prefetched relation invalidates the cache. - Works with: Django 2.0+ (all features stable). Django 5.2 LTS and 6.0 recommended for
latest ORM optimizations including async prefetch support. Django 6.0 breaking change:
get_prefetch_queryset()andPrefetch.get_current_queryset()were removed — custom prefetcher subclasses must migrate toget_prefetcher()/prefetch_related_objects(). Requires Python 3.12+.
Constraints
select_related()works ONLY with ForeignKey and OneToOneField — using it on ManyToMany or reverse FK silently falls back to lazy loading [src1]- Calling
.filter(),.all(), or.exclude()on a prefetched relationship creates a new queryset and re-queries the database, ignoring the prefetch cache [src2, src8] - Never call
select_related()without explicit field names in production — the bare form follows ALL FK chains and can produce massive JOINs [src1, src3] - Django 4.2+ async ORM variants (
aselect_related,aprefetch_related) are awaitable and must not be mixed with synchronous code [src1] Prefetch()withto_attrreturns a Python list, not a QuerySet — calling.filter()on the result raisesAttributeError[src2]assertNumQueries()counts vary by Django version, database backend, and middleware — always calibrate expected counts for your specific stack [src3]
Quick Reference
| # | Pattern | Queries | Fix | Result |
|---|---|---|---|---|
| 1 | Loop accessing ForeignKey | 1 + N | select_related('fk_field') |
1 query (SQL JOIN) [src1] |
| 2 | Loop accessing reverse FK | 1 + N | prefetch_related('related_set') |
2 queries [src1] |
| 3 | Loop accessing ManyToMany | 1 + N | prefetch_related('m2m_field') |
2 queries [src1] |
| 4 | Nested relationship (FK → FK) | 1 + N + N² | select_related('fk__nested_fk') |
1 query [src1] |
| 5 | FK + filtered reverse set | 1 + N | Prefetch('related_set', queryset=...) |
2 queries [src2] |
| 6 | Aggregate per related object | 1 + N | annotate(count=Count('related')) |
1 query [src3] |
| 7 | Serializer accessing FK | 1 + N | Override get_queryset() with select_related |
1-2 queries [src1, src4] |
| 8 | Template accessing FK in loop | 1 + N | Pass optimized queryset to template context | 1-2 queries [src1, src5] |
| 9 | Admin list_display with FK | 1 + N | Override get_queryset() in ModelAdmin |
1-2 queries [src3] |
| 10 | Bulk create/update in loop | N inserts/updates | bulk_create() / bulk_update() |
1 query [src3] |
| 11 | Generic relation access | 1 + N | prefetch_related() with GenericPrefetch |
2 queries [src2] |
| 12 | Entire model with many FKs | 1 + N per FK | django-auto-prefetch model base class |
2 queries per FK (automatic) [src9] |
Decision Tree
START
├── What relationship type causes the extra queries?
│ ├── ForeignKey or OneToOneField (forward) → select_related() [src1]
│ ├── ManyToManyField → prefetch_related() [src1]
│ ├── Reverse ForeignKey (related_set) → prefetch_related() [src1]
│ └── Generic relation → prefetch_related() with GenericPrefetch [src2]
├── Do you need to filter/order the related objects?
│ ├── YES → Use Prefetch() object with custom queryset [src2]
│ └── NO → Use plain select_related() or prefetch_related()
├── Do you need an aggregate (count, sum, avg)?
│ ├── YES → Use annotate() instead of fetching objects [src3]
│ └── NO ↓
├── Is it in DRF serializer?
│ ├── YES → Override get_queryset() in ViewSet/APIView [src4]
│ └── NO ↓
├── Is it in a template?
│ ├── YES → Optimize queryset in view before passing to context [src5]
│ └── NO ↓
├── Is it in Django admin?
│ ├── YES → Override get_queryset(), add list_select_related [src3]
│ └── NO ↓
├── Is it bulk create/update?
│ ├── YES → Use bulk_create() / bulk_update() [src3]
│ └── NO ↓
├── Want zero-config automatic prefetching?
│ ├── YES → Use django-auto-prefetch model base class [src9]
│ └── NO ↓
├── Want to prevent any lazy queries in specific code blocks?
│ ├── YES → Use django-zen-queries context manager [src10]
│ └── NO ↓
└── Profile with django-debug-toolbar or nplusone [src6, src7]
Step-by-Step Guide
1. Detect the N+1 problem
Install detection tools to find N+1 queries before they hit production. [src5, src6, src7]
# Install detection tools
pip install django-debug-toolbar nplusone
# Optional: zero-config prevention
pip install django-auto-prefetch
# Optional: explicit query control
pip install django-zen-queries
# settings.py — Enable debug toolbar and nplusone
INSTALLED_APPS = [..., 'debug_toolbar', 'nplusone.ext.django']
MIDDLEWARE = [
'debug_toolbar.middleware.DebugToolbarMiddleware',
'nplusone.ext.django.NPlusOneMiddleware',
...
]
INTERNAL_IPS = ['127.0.0.1']
NPLUSONE_RAISE = True # Raise in development
Verify: Open django-debug-toolbar SQL panel — if you see N similar queries that differ only by ID, you have an N+1 problem.
2. Use select_related() for ForeignKey / OneToOne
select_related() performs a SQL JOIN to fetch related objects in the same query. [src1]
# BAD — N+1: 1 + N queries
books = Book.objects.all()
for book in books:
print(book.author.name) # Each triggers a query!
# GOOD — FIX: 1 query (SQL JOIN)
books = Book.objects.select_related('author').all()
for book in books:
print(book.author.name) # Already loaded
# Chain for nested relationships
books = Book.objects.select_related('author__publisher').all()
Verify: SQL panel shows 1 query with JOIN instead of N+1.
3. Use prefetch_related() for ManyToMany / reverse FK
prefetch_related() executes a separate query and joins in Python. [src1]
# GOOD — FIX: 2 queries total
books = Book.objects.prefetch_related('tags').all()
for book in books:
print(book.tags.all()) # Already prefetched
# Reverse FK
authors = Author.objects.prefetch_related('book_set').all()
Verify: Debug toolbar shows exactly 2 queries instead of N+1.
4. Use Prefetch() for filtered/custom querysets
The Prefetch object allows filtering, ordering, or annotating prefetched items. [src2, src8]
from django.db.models import Prefetch
authors = Author.objects.prefetch_related(
Prefetch(
'book_set',
queryset=Book.objects.filter(published=True).order_by('-pub_date')[:5],
to_attr='recent_books' # Access via author.recent_books (list)
)
)
Verify: 2-3 queries total regardless of data size.
5. Use annotate() for aggregates
Don't fetch all related objects just to count them. [src3]
from django.db.models import Count, Avg, Q
authors = Author.objects.annotate(
book_count=Count('book'),
published_count=Count('book', filter=Q(book__published=True)),
avg_rating=Avg('book__rating'),
)
Verify: 1 query with GROUP BY instead of N+1.
6. Fix N+1 in Django REST Framework
DRF serializers are a very common source of N+1 queries. [src4]
# GOOD — Override get_queryset
class BookViewSet(viewsets.ModelViewSet):
serializer_class = BookSerializer
def get_queryset(self):
return Book.objects.select_related('author').all()
Verify: API response triggers 1 query instead of N+1.
7. Fix N+1 in Django Admin
Admin list views commonly trigger N+1 queries. [src3]
class BookAdmin(admin.ModelAdmin):
list_display = ['title', 'author_name', 'publisher_name']
list_select_related = ['author', 'publisher']
def get_queryset(self, request):
return super().get_queryset(request).select_related(
'author', 'publisher'
).prefetch_related('tags')
Verify: Admin list page uses 1-2 queries regardless of page size.
8. Use django-auto-prefetch for zero-config prevention
django-auto-prefetch automatically detects when a ForeignKey or OneToOneField is accessed in a
loop and speculatively prefetches it for all instances in the same queryset. Supports Django 4.2 through 6.0.
[src9]
# models.py — Replace standard imports with auto_prefetch
import auto_prefetch
class Author(auto_prefetch.Model):
name = models.CharField(max_length=100)
class Meta(auto_prefetch.Model.Meta):
pass
class Book(auto_prefetch.Model):
title = models.CharField(max_length=200)
author = auto_prefetch.ForeignKey(Author, on_delete=models.CASCADE)
class Meta(auto_prefetch.Model.Meta):
pass
# Now this automatically prefetches — no select_related needed
books = Book.objects.all()
for book in books:
print(book.author.name) # Auto-prefetched on first access
Code Examples
Comprehensive queryset optimizer
Full script: comprehensive-queryset-optimizer.py (68 lines)
# Input: Views with inconsistent query optimization
# Output: Reusable queryset manager that ensures optimal loading
from django.db import models
from django.db.models import Count, Prefetch
class BookQuerySet(models.QuerySet):
def with_author(self):
return self.select_related('author')
def with_tags(self):
return self.prefetch_related('tags')
def with_recent_reviews(self, limit=5):
return self.prefetch_related(
Prefetch(
'reviews',
queryset=Review.objects.select_related('user')
.order_by('-created_at')[:limit],
to_attr='recent_reviews'
)
)
def with_stats(self):
return self.annotate(
review_count=Count('reviews'),
tag_count=Count('tags', distinct=True),
)
def for_list_view(self):
return self.with_author().with_tags().with_stats().only(
'id', 'title', 'slug', 'pub_date', 'author__name'
)
def for_detail_view(self):
return self.with_author().with_tags().with_recent_reviews().with_stats()
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey('Author', on_delete=models.CASCADE)
tags = models.ManyToManyField('Tag')
objects = BookQuerySet.as_manager()
# Usage: Book.objects.for_list_view()
assertNumQueries test helper
Full script: assertnumqueries-test-helper.py (36 lines)
# Input: Need to catch N+1 regressions in tests
# Output: Test patterns that enforce query count limits
from django.test import TestCase
class BookAPITest(TestCase):
def setUp(self):
self.author = Author.objects.create(name="Test Author")
for i in range(10):
Book.objects.create(title=f"Book {i}", author=self.author)
def test_book_list_no_n_plus_1(self):
with self.assertNumQueries(2):
response = self.client.get('/api/books/')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()['results']), 10)
def test_book_list_scales(self):
for i in range(90):
Book.objects.create(title=f"Extra {i}", author=self.author)
with self.assertNumQueries(2): # Still 2, not 101
response = self.client.get('/api/books/')
Query count middleware for development
Full script: query-count-middleware-for-development.py (41 lines)
# Input: Detect N+1 queries without debug toolbar
# Output: Middleware that logs warnings on high query counts
import logging
from django.db import connection, reset_queries
from django.conf import settings
logger = logging.getLogger('queries')
class QueryCountMiddleware:
THRESHOLD = 10
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if not settings.DEBUG:
return self.get_response(request)
reset_queries()
response = self.get_response(request)
query_count = len(connection.queries)
total_time = sum(float(q['time']) for q in connection.queries)
if query_count > self.THRESHOLD:
logger.warning(
f"WARNING {request.method} {request.path}: "
f"{query_count} queries ({total_time:.3f}s)"
)
seen = {}
for q in connection.queries:
normalized = q['sql'][:100]
seen[normalized] = seen.get(normalized, 0) + 1
dupes = {k: v for k, v in seen.items() if v > 1}
if dupes:
logger.warning(f" Duplicate queries: {dupes}")
return response
Anti-Patterns
Wrong: Accessing ForeignKey in a loop
# BAD — N+1 queries [src1, src5]
books = Book.objects.all()
for book in books:
print(f"{book.title} by {book.author.name}") # N extra queries!
Correct: select_related before the loop
# GOOD — 1 query with SQL JOIN [src1]
books = Book.objects.select_related('author').all()
for book in books:
print(f"{book.title} by {book.author.name}") # Already loaded
Wrong: Counting related objects in a loop
# BAD — 1 for authors + N count queries [src3]
authors = Author.objects.all()
data = [{'name': a.name, 'books': a.book_set.count()} for a in authors]
Correct: annotate() aggregates in one query
# GOOD — 1 query with COUNT annotation [src3]
from django.db.models import Count
authors = Author.objects.annotate(book_count=Count('book'))
data = [{'name': a.name, 'books': a.book_count} for a in authors]
Wrong: DRF serializer without queryset optimization
# BAD — Serializer accesses FK, causing N+1 [src4]
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all() # No select_related!
serializer_class = BookSerializer # Accesses book.author.name
Correct: Override get_queryset with eager loading
# GOOD — Eager-load everything the serializer needs [src4]
class BookViewSet(viewsets.ModelViewSet):
serializer_class = BookSerializer
def get_queryset(self):
return Book.objects.select_related(
'author', 'publisher'
).prefetch_related('tags')
Wrong: Filtering a prefetched relationship
# BAD — .filter() on prefetched set re-queries the database [src2, src8]
books = Book.objects.prefetch_related('tags').all()
for book in books:
# This ignores the prefetch cache and hits the DB!
python_tags = book.tags.filter(name__startswith='python')
Correct: Use Prefetch() with to_attr for filtered access
# GOOD — Filter inside Prefetch, access via to_attr [src2, src8]
from django.db.models import Prefetch
books = Book.objects.prefetch_related(
Prefetch(
'tags',
queryset=Tag.objects.filter(name__startswith='python'),
to_attr='python_tags'
)
)
for book in books:
print(book.python_tags) # List, already filtered, no extra query
Common Pitfalls
select_relatedvsprefetch_relatedconfusion:select_relatedworks only for ForeignKey/OneToOne (creates SQL JOIN).prefetch_relatedworks for ManyToMany and reverse ForeignKey (separate query + Python join). Using the wrong one fails silently. [src1]prefetch_relatedinvalidated by.filter(): Calling.filter()on a prefetched queryset triggers a new query. UsePrefetch()object with a filtered queryset andto_attrinstead. [src2, src8]- Template lazy loading: Django templates access related objects lazily —
{{ book.author.name }}triggers a query even though it doesn't look like Python code. Optimize querysets in views. [src5] - DRF serializer
depth: Settingdepth = 1serializes related objects, triggering lazy loads for each row. Overrideget_queryset()to match. [src4] only()breakingselect_related: If you useonly()but forget the FK field, Django falls back to lazy loading. Always include FK columns inonly(). [src3]- Ordering by related field:
Book.objects.order_by('author__name')doesn't join — it triggers a separate query. Combine withselect_related('author'). [src1] select_relatedwithvalues(): Usingselect_related()alongsidevalues()is pointless —values()returns dicts, not model instances. Usevalues('author__name')directly. [src3]- Prefetch cache not shared across querysets: Each queryset maintains its own prefetch cache. Cloning a queryset (slicing, re-filtering) creates a new cache. Prefetch once and iterate. [src2]
Diagnostic Commands
# Django shell: count queries
from django.db import connection, reset_queries
from django.conf import settings
settings.DEBUG = True
reset_queries()
# ... your code here ...
books = Book.objects.all()
for book in books:
_ = book.author.name
print(f"Total queries: {len(connection.queries)}")
for q in connection.queries:
print(f" [{q['time']}s] {q['sql'][:120]}")
# Enable SQL logging in settings.py
# LOGGING = { 'loggers': { 'django.db.backends': { 'level': 'DEBUG' } } }
# Install detection tools
pip install django-debug-toolbar nplusone
# nplusone logs: "Potential n+1 query detected on `Book.author`"
# Use django-zen-queries to enforce no-query zones
pip install django-zen-queries
# In views: with queries_disabled(): render(request, template, context)
Version History & Compatibility
| Version | Behavior | Key Changes |
|---|---|---|
| Django 6.0 (2025-12) | Stable (Python 3.12+ required) | get_prefetch_queryset() and Prefetch.get_current_queryset() REMOVED
— custom prefetcher subclasses must migrate to get_prefetcher() /
prefetch_related_objects(). StringAgg on all backends;
BigAutoField default; AnyValue aggregate added [src1]
|
| Django 5.2 LTS (2025-04) | Stable (recommended) | values()/values_list() ordering now matches specified order;
prefetch_related bug fixes for composite PKs [src1]
|
| Django 5.1 (2024-08) | Stable | Composite primary keys; bulk_create() used in admin delete action [src1]
|
| Django 5.0 (2023-12) | Stable | GeneratedField; improved query compilation [src1]
|
| Django 4.2 LTS (2023-04) | Stable | Async ORM: aselect_related(), aprefetch_related() [src1]
|
| Django 4.1 (2022-08) | Prefetch improvements |
Async ORM methods; better Prefetch debugging [src2] |
| Django 3.2 LTS (2021-04) | Stable | prefetch_related performance improvements [src1]
|
| Django 2.0 (2017-12) | Prefetch object | Prefetch() for custom querysets [src2]
|
| Django 1.11 (2017-04) | list_select_related |
Admin list_select_related attribute [src3] |
| Django 1.4 (2012-03) | prefetch_related |
ManyToMany eager loading support [src1] |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Loop accessing related FK objects | Single object detail view (1 related) | Just access the FK normally |
| Template displays list with related data | Aggregating, not displaying individuals | annotate() + aggregate() |
| DRF serializer includes nested objects | Related data is optional / rarely needed | Conditional prefetching |
| Admin list shows FK columns | QuerySet is already filtered to 1 result | No optimization needed |
| Management command iterating large queryset | Read-heavy endpoint already using values() |
values('fk__field') directly |
Important Caveats
select_related()uses SQL JOINs which can produce large result sets for deeply nested relationships. Limit depth with explicit field names rather thanselect_related()(all FK fields).prefetch_related()does the joining in Python. For very large related sets, this can cause high memory usage. UsePrefetch()with.filter()or slicing to limit.- Calling
.all(),.filter(), or.exclude()on a prefetched relationship creates a new queryset and hits the database again, ignoring the prefetch cache. assertNumQueries()counts vary with Django version, database backend, and middleware. Set expected counts based on your specific setup.- Django 4.2+ provides async variants (
aselect_related,aprefetch_related) for async views — they are awaitable and cannot be mixed with sync code. django-auto-prefetchis a third-party package (not Django core). It modifies model inheritance and may conflict with custom managers or querysets. Test thoroughly before adopting in existing projects. [src9]- OpenTelemetry and Sentry both offer N+1 detection in production. Consider enabling performance monitoring if debug toolbar and nplusone are insufficient for catching production-only N+1 issues. [src4]