select_related() (ForeignKey/OneToOne — SQL JOIN) or prefetch_related()
(ManyToMany/reverse FK — separate query + Python join).django-debug-toolbar shows all SQL queries per request
with timing. For automated detection: nplusone library logs warnings when N+1 patterns are
detected. For zero-config fix: django-auto-prefetch automatically prefetches ForeignKey
values as needed. For tests: self.assertNumQueries(N).{% for book in books %}{{ book.author.name }}{% endfor %} fires N author queries. Also:
calling .filter() on a prefetched relation invalidates the cache.select_related() works ONLY with ForeignKey and OneToOneField — using it on ManyToMany or
reverse FK silently falls back to lazy loading [src1].filter(), .all(), or .exclude() on a prefetched
relationship creates a new queryset and re-queries the database, ignoring the prefetch cache [src2, src8]select_related() without explicit field names in production — the bare form
follows ALL FK chains and can produce massive JOINs [src1, src3]aselect_related, aprefetch_related) are
awaitable and must not be mixed with synchronous code [src1]Prefetch() with to_attr returns a Python list, not a QuerySet — calling
.filter() on the result raises AttributeError [src2]assertNumQueries() counts vary by Django version, database backend, and middleware — always
calibrate expected counts for your specific stack [src3]| # | 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] |
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]
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.
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.
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.
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.
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.
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.
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.
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
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()
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/')
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
# 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!
# 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
# 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]
# 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]
# 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
# 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')
# 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')
# 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
select_related vs prefetch_related confusion:
select_related works only for ForeignKey/OneToOne (creates SQL JOIN).
prefetch_related works for ManyToMany and reverse ForeignKey (separate query + Python
join). Using the wrong one fails silently. [src1]prefetch_related invalidated by .filter(): Calling
.filter() on a prefetched queryset triggers a new query. Use Prefetch() object
with a filtered queryset and to_attr instead. [src2, src8]{{ book.author.name }} triggers a query even though it doesn't look like Python code.
Optimize querysets in views. [src5]depth: Setting depth = 1 serializes related
objects, triggering lazy loads for each row. Override get_queryset() to match. [src4]
only() breaking select_related: If you use
only() but forget the FK field, Django falls back to lazy loading. Always include FK
columns in only(). [src3]Book.objects.order_by('author__name') doesn't
join — it triggers a separate query. Combine with select_related('author'). [src1]select_related with values(): Using
select_related() alongside values() is pointless — values()
returns dicts, not model instances. Use values('author__name') directly. [src3]# 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 | Behavior | Key Changes |
|---|---|---|
| Django 6.0 (2025-12) | Stable | StringAgg on all backends; GeneratedField refresh on save;
BigAutoField default. No N+1-specific changes [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] |
| 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 |
select_related() uses SQL JOINs which can produce large result sets for deeply nested
relationships. Limit depth with explicit field names rather than select_related() (all FK
fields).prefetch_related() does the joining in Python. For very large related sets, this can cause
high memory usage. Use Prefetch() with .filter() or slicing to limit..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.aselect_related, aprefetch_related) for
async views — they are awaitable and cannot be mixed with sync code.django-auto-prefetch is 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]