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 useIntegerField()instead ofSerializerMethodField. - Use
prefetch_related("comments")andlen(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
JsonResponsefor downloads. - Switch to
orjsonrenderer (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
select_relatedevery FK you serialize.prefetch_relatedevery reverse FK / M2M you serialize.- Replace
SerializerMethodFieldcounts with.annotate(Count(...)). - Add a
CaptureQueriesContexttest that locks query count. - Paginate. Set
max_page_size. - For >10k row sets switch to
CursorPagination. - 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. - “
SerializerMethodFielddoingobj.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 plainIntegerField, or withprefetch_related+len(obj.comments.all()). - “How would you assert in tests that an endpoint doesn’t N+1?” —
CaptureQueriesContextaround 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_relatedvsprefetch_related?” — JOIN single query for ForeignKey/OneToOne; second query joined in Python for reverse-FK/M2M. - “Where can
ModelSerializeritself add hidden queries?” — autoUniqueValidatorperunique=Truefield on writes; one extraSELECTper such field per request.