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— aForeignKeytoBlogSeries -
tags— aManyToManyFieldtoBlogTag
When DRF serializes a list of 30 posts, it accesses post.series and post.tags on each one. Without eager loading, 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 loading.
The Fix
Django gives me two tools for this:
-
select_related()— forForeignKeyandOneToOnerelations. Issues a SQLJOINand fetches everything in a single query. -
prefetch_related()— forManyToManyand 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:
# Before
queryset = Testimonial.objects.all()
# After
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_relatedorprefetch_related - The serializer accesses a related field (
source="relation.field", nested serializers,SerializerMethodFieldthat touchesobj.relation)
Tools that help catch this before Sentry does:
- django-debug-toolbar — shows query counts per request in the browser
- nplusone — raises exceptions in tests when N+1 queries are detected
- 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.
Top comments (0)