backend / web frameworks / django / drf / 12_performance_n_plus_1.md

DRF Performance and the N+1 Problem

6 interview angles 4 min read source

DRF Performance and the N+1 Problem

DRF’s biggest performance footgun is N+1: a list endpoint serializes 100 objects, and each object triggers an extra query. A page that should be 1 query becomes 101.

The classic N+1

class CommentSerializer(serializers.ModelSerializer):
    author_name = serializers.CharField(source="author.name")
    class Meta:
        model = Comment
        fields = ["id", "body", "author_name"]

class CommentList(generics.ListAPIView):
    queryset = Comment.objects.all()
    serializer_class = CommentSerializer

Looks fine. Reality: 1 query for comments + 1 query per comment to fetch author.name. 50 comments → 51 queries.

The fixes — ORM-level, not DRF-level

Relationship Use
ForeignKey, OneToOne select_related("author") — single SQL JOIN
Reverse FK, M2M prefetch_related("comments") — second query, joined in Python
Reverse FK with filtering/ordering prefetch_related(Prefetch("comments", queryset=Comment.objects.filter(...)))

Fix:

queryset = Comment.objects.select_related("author").all()

Now: 1 query.

For the M2M case:

queryset = Post.objects.prefetch_related("tags").select_related("author")

See ../11_select_related_vs_prefetch_related.md for the deep dive.

Where DRF specifically hides queries

1. SerializerMethodField

class PostSerializer(serializers.ModelSerializer):
    comment_count = serializers.SerializerMethodField()

    def get_comment_count(self, obj):
        return obj.comments.count()    # one query per post

Two fixes:

  • Annotate in the queryset: .annotate(comment_count=Count("comments")) and use IntegerField() instead of SerializerMethodField.
  • Use prefetch_related("comments") and len(obj.comments.all()) (works because the prefetched list is in memory).

.count() always re-queries, even after prefetch.

2. Hyperlinked relations

class PostSerializer(serializers.HyperlinkedModelSerializer):
    author = serializers.HyperlinkedRelatedField(view_name="author-detail", read_only=True)

By default HyperlinkedRelatedField doesn’t trigger queries (it builds the URL from the FK id). But if you accidentally use a view_name whose URL needs a slug from the related table, you’ll re-fetch each related object.

3. unique=True validators on writes

ModelSerializer auto-attaches a UniqueValidator per unique=True field. Each runs a SELECT ... WHERE field = ? on every write request. Usually fine, but for bulk creates (50 items, 3 unique fields) that’s 150 extra queries.

Skip with extra_kwargs = {"slug": {"validators": []}} if you handle uniqueness in a transaction (catch IntegrityError).

4. Nested serializers without prefetch

Read-only nested children look innocent:

comments = CommentSerializer(many=True, read_only=True)

But each parent triggers obj.comments.all() — N+1 on the list endpoint. Fix: prefetch_related("comments").

For deep nesting:

queryset = Post.objects.prefetch_related(
    Prefetch("comments", queryset=Comment.objects.select_related("author"))
)

Detecting N+1 in dev

# settings.py for dev only
LOGGING = {
    "version": 1,
    "loggers": {
        "django.db.backends": {"handlers": ["console"], "level": "DEBUG"},
    },
    "handlers": {"console": {"class": "logging.StreamHandler"}},
}

Or use django-debug-toolbar (browser overlay) or nplusone (raises an exception when it spots one).

In CI/tests:

from django.test.utils import CaptureQueriesContext
from django.db import connection

def test_list_no_nplus1(client):
    Comment.objects.bulk_create([... 20 comments ...])
    with CaptureQueriesContext(connection) as ctx:
        client.get("/api/comments/")
    assert len(ctx) <= 5  # whatever your budget is

This is the single most useful test pattern for DRF performance. Catches regressions when someone adds a new SerializerMethodField.

Beyond N+1

Decimal and DateTime serialization is slow

For huge responses (10k+ rows) with many DecimalField or DateTimeField, DRF’s per-field rendering dominates. Options:

  • Use .values() + manual JSON instead of a serializer for export endpoints.
  • Skip DRF entirely and stream JsonResponse for downloads.
  • Switch to orjson renderer (drf-orjson-renderer) — 5–10× faster JSON encoding.

Pagination

A 10k-row list response with full nested serialization is 10k× slower than a 50-row page. Always paginate. See 09_pagination.md. For deep paging, use CursorPagination.

count() on huge tables

PageNumberPagination calls SELECT COUNT(*) on every page. On a 50M-row table that’s seconds. Either switch to CursorPagination or override get_paginated_response to drop count.

Serializer instantiation cost

Creating a ModelSerializer does reflection on Model._meta — measurable on hot paths. Don’t instantiate one in a tight loop; reuse self.get_serializer().

to_representation overrides that re-query

def to_representation(self, instance):
    data = super().to_representation(instance)
    data["latest_event"] = instance.events.order_by("-ts").first().payload  # query per row
    return data

Same fix as SerializerMethodField: prefetch with a Prefetch(... queryset=...).

Optimization checklist for any DRF list endpoint

  1. select_related every FK you serialize.
  2. prefetch_related every reverse FK / M2M you serialize.
  3. Replace SerializerMethodField counts with .annotate(Count(...)).
  4. Add a CaptureQueriesContext test that locks query count.
  5. Paginate. Set max_page_size.
  6. For >10k row sets switch to CursorPagination.
  7. For exports, bypass DRF — stream raw JSON or CSV.

Interview angle

  • “What’s the most common DRF performance issue you’ve debugged?” — N+1 from a serializer field that traverses a relationship without select_related/prefetch_related.
  • SerializerMethodField doing obj.comments.count() — why is that bad and how do you fix it?” — one query per row; replace with .annotate(Count("comments")) on the queryset and a plain IntegerField, or with prefetch_related + len(obj.comments.all()).
  • “How would you assert in tests that an endpoint doesn’t N+1?”CaptureQueriesContext around the request, assert query count ≤ budget.
  • “Why is SELECT COUNT(*) a problem on large tables, and what’s the fix?” — full table scan; switch to cursor pagination or drop the count from the response.
  • select_related vs prefetch_related?” — JOIN single query for ForeignKey/OneToOne; second query joined in Python for reverse-FK/M2M.
  • “Where can ModelSerializer itself add hidden queries?” — auto UniqueValidator per unique=True field on writes; one extra SELECT per such field per request.