iToverDose/Software· 23 MAY 2026 · 12:05

Silent performance killer: How to detect and fix N+1 queries in Django

N+1 queries drain your API's speed without errors—until production traffic reveals the hidden cost. Learn how to spot and eliminate this silent performance killer in Django with minimal code changes.

DEV Community3 min read0 Comments

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() for ForeignKey and OneToOne relations, which uses SQL JOIN to fetch related data in a single query.
  • prefetch_related() for ManyToMany and 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 IN clause
  • 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.

Comments

00
LEAVE A COMMENT
ID #27653Z

0 / 1200 CHARACTERS

Human check

3 + 9 = ?

Will appear after editor review

Moderation · Spam protection active

No approved comments yet. Be first.