How to Detect and Fix N+1 Query Problems in Django

Type: Software Reference Confidence: 0.94 Sources: 10 Verified: 2026-02-23 Freshness: quarterly

TL;DR

Constraints

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

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 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]

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

Related Units