Fixing N+1 Query Problems in Django REST Framework Serializers
Your Django API works fine in development with a handful of records. Then it hits production with thousands of rows and suddenly a single list endpoint takes three seconds to respond. The culprit is almost always N+1 queries hiding inside your serializers.
This article shows you exactly how to find them, understand why DRF makes them easy to introduce accidentally, and fix them for good.
What you'll learn
- Why N+1 queries happen inside DRF serializers and not just in views
- How to detect them using Django's query logging and
django-debug-toolbar - How to apply
select_relatedandprefetch_relatedcorrectly in your viewsets - How to handle nested serializers and
SerializerMethodFieldwithout triggering extra queries - A pattern for writing a reusable queryset mixin that keeps your views clean
What is an N+1 query?
An N+1 problem occurs when your code executes one query to fetch a list of N objects, then executes one additional query per object to fetch related data. If you return 200 books and each book looks up its author separately, you just ran 201 database queries for a single request.
Django's ORM is lazy by default. Related objects are not fetched until you access them. That's a useful feature in isolation, but inside a serializer that iterates over a queryset, it means every attribute access on a related model fires a new SQL statement.
A concrete example of the problem
Imagine a simple book API. You have a Book model with a foreign key to Author, and each author has a Profile with a one-to-one relationship.
# models.py
class Author(models.Model):
name = models.CharField(max_length=200)
class Profile(models.Model):
author = models.OneToOneField(Author, on_delete=models.CASCADE, related_name='profile')
bio = models.TextField()
class Book(models.Model):
title = models.CharField(max_length=300)
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name='books')
published_year = models.IntegerField()
Now a serializer that looks totally reasonable at first glance:
# serializers.py
class AuthorSerializer(serializers.ModelSerializer):
bio = serializers.CharField(source='profile.bio')
class Meta:
model = Author
fields = ['id', 'name', 'bio']
class BookSerializer(serializers.ModelSerializer):
author = AuthorSerializer()
class Meta:
model = Book
fields = ['id', 'title', 'published_year', 'author']
And the view:
# views.py
class BookViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
For a list of 50 books, Django executes: 1 query for books + 50 queries for each author + 50 queries for each author's profile = 101 queries. Add any more nested relations and it compounds fast.
How to detect N+1 queries
Before you fix anything, you need to confirm the problem. There are two reliable approaches.
Option 1: Django's connection.queries
In a shell or test, enable query logging and inspect the output:
from django.db import connection, reset_queries
from django.test.utils import override_settings
# Make sure DEBUG=True for queries to be logged
reset_queries()
# ... run your serializer here ...
print(len(connection.queries))
for q in connection.queries:
print(q['sql'])
If you see the same query repeated with different primary key values, you have an N+1.
Option 2: django-debug-toolbar
Install django-debug-toolbar and open any list endpoint in your browser. The SQL panel shows every query, how long it took, and whether it was duplicated. It highlights repeated queries in red, which makes N+1 problems obvious within seconds.
pip install django-debug-toolbar
Add it to INSTALLED_APPS and your URL config following the official setup steps. It only activates when DEBUG=True, so it's safe to leave configured in your local settings.
Fixing N+1 with select_related and prefetch_related
The fix usually lives in the queryset, not the serializer. Django provides two tools: select_related for foreign key and one-to-one relationships (SQL JOIN), and prefetch_related for many-to-many and reverse foreign key relationships (separate optimized query cached in Python).
For the book example above, one line in the viewset fixes both levels of N+1:
class BookViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Book.objects.select_related('author__profile').all()
serializer_class = BookSerializer
The double underscore traversal author__profile tells Django to JOIN through author and then JOIN to profile in a single SQL statement. Your 101 queries become 1.
For reverse relations, use prefetch_related. Say each author has multiple tags:
queryset = Book.objects.select_related('author__profile').prefetch_related('author__tags').all()
Django runs two queries total: one for books with authors and profiles joined, one bulk fetch of all relevant tags. It then assembles the relationship in Python memory. Much faster than N individual lookups.
Handling SerializerMethodField without extra queries
SerializerMethodField is where N+1 problems hide most effectively because the access pattern is inside a method you wrote, which is easy to overlook.
# This looks innocent but can cause N+1
class BookSerializer(serializers.ModelSerializer):
latest_review = serializers.SerializerMethodField()
class Meta:
model = Book
fields = ['id', 'title', 'latest_review']
def get_latest_review(self, obj):
# This hits the DB once per book!
return obj.reviews.order_by('-created_at').values('text').first()
The fix is to prefetch the reviews in the viewset and use the prefetch cache inside the method:
from django.db.models import Prefetch
from .models import Review
class BookViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Book.objects.prefetch_related(
Prefetch(
'reviews',
queryset=Review.objects.order_by('-created_at'),
to_attr='prefetched_reviews'
)
).all()
serializer_class = BookSerializer
def get_latest_review(self, obj):
reviews = getattr(obj, 'prefetched_reviews', [])
if reviews:
return reviews[0].text
return None
The Prefetch object with to_attr stores the prefetched queryset as a Python list on each object. Accessing it in get_latest_review never touches the database. The important detail is that obj.prefetched_reviews is a plain list, not a queryset, so you access it by index rather than calling .first().
A reusable queryset optimization mixin
As your API grows, you'll have the same queryset optimization spread across many viewsets. A simple mixin keeps it centralized and makes it easy to test in isolation.
# mixins.py
class OptimizedBookQuerysetMixin:
"""Applies standard eager-loading for Book queries."""
def get_queryset(self):
qs = super().get_queryset()
return qs.select_related(
'author__profile'
).prefetch_related(
Prefetch(
'reviews',
queryset=Review.objects.order_by('-created_at'),
to_attr='prefetched_reviews'
),
'author__tags'
)
class BookViewSet(OptimizedBookQuerysetMixin, viewsets.ReadOnlyModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
The mixin calls super().get_queryset() so it chains cleanly with DRF's built-in filtering, searching, and ordering. Your base queryset on the viewset stays simple; the mixin handles performance concerns separately.
Common pitfalls to avoid
Chaining querysets incorrectly. If you call .filter() after prefetch_related in certain ways, Django may discard the prefetch cache and re-query. Apply filters before or during the Prefetch object's own queryset, not after the main queryset is evaluated.
Using values() or values_list() in nested serializers. values() returns dictionaries, not model instances, so related object access is lost. Stick to model instances and let select_related and prefetch_related do the join work.
Forgetting pagination context. Pagination limits the number of objects serialized, which hides N+1 in testing. A page size of 10 means only 11 queries instead of 1001. The problem is still there β it just isn't obvious until page size increases.
Calling the ORM inside a custom to_representation method. If you override to_representation on a serializer and access related objects there without prefetching, you get N+1 again. Treat to_representation the same way you treat SerializerMethodField: only read from prefetched or annotated data.
Assuming nested writable serializers are safe. Writable nested serializers can trigger additional queries during validation (checking uniqueness constraints, for example). Profile your write endpoints too, not just your read ones.
Verifying the fix
After applying your optimizations, use assertNumQueries in your test suite to lock in the expected query count. This prevents regressions from future changes to serializers or querysets.
# tests.py
from django.test import TestCase
from rest_framework.test import APIClient
class BookListQueryCountTest(TestCase):
def setUp(self):
# Create test data: authors, profiles, books
...
def test_list_endpoint_query_count(self):
client = APIClient()
with self.assertNumQueries(3): # adjust to your expected count
response = client.get('/api/books/')
self.assertEqual(response.status_code, 200)
Pin the query count to what you measured after fixing, then add a comment explaining what each query does. When someone adds a new related field to the serializer and the test fails, they'll know exactly why and where to add the corresponding prefetch.
Wrapping up
N+1 queries in DRF serializers are easy to introduce and easy to miss in development. Here's what to do next:
- Install
django-debug-toolbarand open your most-used list endpoints to check the SQL panel right now. - Audit your viewset querysets and add
select_relatedfor foreign key and one-to-one traversals. - Replace raw reverse-relation accesses in
SerializerMethodFieldwithPrefetchobjects andto_attr. - Add
assertNumQueriestests for your critical endpoints so regressions surface in CI before they reach production. - Refactor repeated queryset logic into a mixin or a custom manager method so the optimization stays in one place.
Once you internalize the pattern β optimize the queryset, read only from cached data in the serializer β you'll write fast DRF APIs by default rather than chasing performance problems after the fact.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!