Every performant API eventually runs into the same silent killer: the N+1 query problem. It doesn't crash your app. It doesn't throw errors. It just quietly makes every list endpoint slower as your data grows β and it's almost invisible until Sentry flags it in production.
Today, Sentry caught one on my /api/blog-posts/
endpoint. Here's exactly what happened and how I fixed it in three lines of code.
What Is an N+1 Query? #
An N+1 query happens when your code fetches a list of N records, then fires an additional query per record to fetch related data β totalling 1 + N
database hits instead of a flat 2 or 3.
In Django, this usually happens silently because the ORM is lazy by default. Accessing a related object on a model instance that wasn't eagerly loaded triggers a fresh SELECT
on the spot. With 30 blog posts, that's 30 silent queries you never wrote.
The Offending Code #
The BlogPostViewSet
looked clean on the surface:
class BlogPostViewSet(viewsets.ReadOnlyModelViewSet):
queryset = BlogPost.objects.all()
serializer_class = BlogPostSerializer
lookup_field = "uid"
And the serializer:
class BlogPostSerializer(serializers.ModelSerializer):
tags = BlogTagSerializer(many=True, read_only=True)
series = BlogSeriesSerializer(read_only=True)
...
Spot the problem? BlogPost
has two relations:
series
β aForeignKey
toBlogSeries
tags
β aManyToManyField
toBlogTag
When DRF serializes a list of 30 posts, it accesses post.series
and post.tags
on each one. Without eager , Django fires two extra queries per post β one to fetch the series, one to fetch the tags. That's 1 + 60 queries for a 30-post list.
The featured
action had the same issue:
@action(detail=False, methods=["get"])
def featured(self, request):
queryset = BlogPost.objects.filter(date_published__isnull=False).order_by(
"-date_published",
)[:3]
A fresh BlogPost.objects
call with no eager .
The Fix #
Django gives me two tools for this:
β forselect_related()
ForeignKey
andOneToOne
relations. Issues a SQLJOIN
and fetches everything in a single query. -
β forprefetch_related()
ManyToMany
and reverse FK relations. Issues a second query and caches the results in Python.
The fix:
class BlogPostViewSet(viewsets.ReadOnlyModelViewSet):
queryset = BlogPost.objects.select_related("series").prefetch_related("tags")
serializer_class = BlogPostSerializer
lookup_field = "uid"
@action(detail=False, methods=["get"])
def featured(self, request):
queryset = (
BlogPost.objects.select_related("series")
.prefetch_related("tags")
.filter(date_published__isnull=False)
.order_by("-date_published")[:3]
)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
With 30 posts, the list endpoint now costs 3 queries regardless of dataset size:
SELECT * FROM core_blogpost ...
SELECT * FROM core_blogseries WHERE id IN (...)
SELECT * FROM core_blogtag INNER JOIN core_blogpost_tags WHERE blogpost_id IN (...)
The Bonus Fix #
While auditing the blog endpoint, I spotted the same pattern in TestimonialViewSet
. Its serializer accesses project.title
and project.slug
, but the queryset had no select_related
:
queryset = Testimonial.objects.all()
queryset = Testimonial.objects.select_related("project")
One extra line, one less N+1.
How to Spot This in Your Own Code #
The pattern is always the same β look for any ViewSet or view where:
- The queryset has no
select_related
orprefetch_related
- The serializer accesses a related field (
source="relation.field"
, nested serializers,SerializerMethodField
that touchesobj.relation
)
Tools that help catch this before Sentry does:
β shows query counts per request in the browserdjango-debug-toolbar - β raises exceptions in tests when N+1 queries are detectednplusone - Sentry Performanceβ catches it in production with query traces
The best time to catch an N+1 is during code review. Any time you write a nested serializer, ask: does the queryset for this view eagerly load this relation?
Takeaway #
The Django ORM's lazy evaluation is a feature, not a bug β but it requires discipline at the queryset layer. A clean-looking viewset with objects.all()
is often hiding a query storm one serializer away.
The rule of thumb: every relation accessed in a serializer needs a corresponding select_related or prefetch_related on the queryset. Make it a checklist item on every PR that touches a ViewSet.