A slow API isn’t always obvious—until your monitoring tools flag it in production. The N+1 query problem lurks in the background, multiplying database hits with every record fetched, yet it rarely triggers errors. Recently, my /api/blog-posts/ endpoint hit this silent performance bottleneck, and here’s how I identified and resolved it with just three lines of code.
Why N+1 queries are a backend developer’s nightmare
An N+1 query occurs when your application fetches a list of records and then executes an additional query for each item to retrieve related data. Instead of fetching everything in two or three queries, you end up with 1 + N database calls—where N is the number of records. In Django, this happens by default because the ORM defers queries until the data is actually needed.
Consider a blog post list endpoint. Each post belongs to a series and can have multiple tags. Without optimization, fetching 30 posts could trigger 60 extra queries—one for each series and one for each tag. These queries are invisible during development but become costly as traffic grows.
The hidden flaw in a clean-looking Django ViewSet
My BlogPostViewSet appeared straightforward:
class BlogPostViewSet(viewsets.ReadOnlyModelViewSet):
queryset = BlogPost.objects.all()
serializer_class = BlogPostSerializer
lookup_field = "uid"And the serializer included nested fields:
class BlogPostSerializer(serializers.ModelSerializer):
tags = BlogTagSerializer(many=True, read_only=True)
series = BlogSeriesSerializer(read_only=True)The issue? BlogPost has two relations: a ForeignKey to BlogSeries and a ManyToManyField to BlogTag. When the serializer processes a list of 30 posts, it accesses post.series and post.tags for each post. Without eager loading, Django executes a separate query for every access—resulting in 61 queries total for a 30-post list.
The featured action had the same flaw:
@action(detail=False, methods=["get"])
def featured(self, request):
queryset = BlogPost.objects.filter(
date_published__isnull=False
).order_by("-date_published")[:3]A fresh queryset with no eager loading meant three posts could still trigger multiple hidden queries.
The three-line fix that cut query overhead by 95%
Django provides two tools to eliminate N+1 queries:
select_related()forForeignKeyandOneToOnerelations, which uses SQLJOINto fetch related data in a single query.
prefetch_related()forManyToManyand reverse relations, which executes a second query and caches results in Python.
The solution was simple:
class BlogPostViewSet(viewsets.ReadOnlyModelViewSet):
queryset = BlogPost.objects.select_related("series").prefetch_related("tags")
serializer_class = BlogPostSerializer
lookup_field = "uid"And for the featured action:
@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 endpoint now uses only three queries regardless of dataset size:
- A single query to fetch all blog posts
- One query for all related series using
INclause
- One query for all related tags via a join table
No viewset is immune—even testimonials
While auditing, I found the same pattern in TestimonialViewSet. Its serializer accessed project.title and project.slug, but the queryset lacked select_related:
# Before
queryset = Testimonial.objects.all()
# After
queryset = Testimonial.objects.select_related("project")One line changed, one N+1 query eliminated.
How to catch N+1 queries before they reach production
The N+1 pattern is consistent: a queryset without eager loading paired with a serializer that accesses related fields. To catch it early:
- Use django-debug-toolbar to monitor query counts per request in the browser during development.
- Enable nplusone in tests to raise exceptions when N+1 queries occur.
- Leverage Sentry Performance to catch hidden query storms in production with detailed traces.
The best defense is prevention. During code review, ask: Does this queryset eagerly load every relation accessed by the serializer? If not, add the corresponding select_related or prefetch_related call.
A simple rule for cleaner Django APIs
The Django ORM’s lazy evaluation is powerful, but it demands discipline at the queryset level. A clean ViewSet with objects.all() often hides a query storm waiting to happen.
Remember this rule: Every relation accessed in a serializer must have a corresponding `select_related` or `prefetch_related` on the queryset. Make it a mandatory checklist item for every pull request touching a ViewSet.
Fixing N+1 queries isn’t about writing more code—it’s about writing smarter code. A few extra lines in the queryset can save thousands of database calls and keep your API fast, even as traffic grows.
AI summary
Django REST Framework kullanarak geliştirdiğiniz API'de gizlenen performans katili N+1 sorgusunu nasıl tespit edip, sadece üç satırlık kod değişikliğiyle nasıl çözebileceğinizi öğrenin.