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 (3)
"It doesn't crash your app... it just quietly makes every list endpoint slower." This is the best description of the N+1 problem I've ever read. It's so easy to miss during local development when your DB has 10 rows. Using Sentry to actually catch the bottleneck in production instead of guessing is a great workflow. select_related and prefetch_related really are lifesavers!
Glad you liked it!
Catching N+1 in tests rather than production is one of the highest-leverage things a QA setup can do. The team I work with hit a variant where the N+1 was hidden inside a serializer method that only triggered on certain user roles, so the "common path" integration tests missed it for three months. Production caught it on a list endpoint when one user happened to have the right role distribution.
What helped after that was a fixture matrix that varied user roles across test cases, not just request shapes. Same endpoint, different role combinations, watching the SQL count. Tedious to set up, paid for itself the first time it tripped.