cd /news/developer-tools/how-i-caught-and-fixed-an-n-1-query-… · home topics developer-tools article
[ARTICLE · art-11280] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

How I Caught and Fixed an N+1 Query in My Django REST API

The article describes how the author identified and fixed an N+1 query problem in a Django REST API, where lazy ORM evaluation caused excessive database queries when serializing related fields like ForeignKey and ManyToMany relationships. The fix involved adding `select_related()` for ForeignKey relations and `prefetch_related()` for ManyToMany relations to the queryset, reducing the query count from 61 to just 3 for a 30-post list endpoint. The author emphasizes that developers should proactively check for N+1 queries during code review whenever nested serializers or related field accesses are present.

read3 min views37 publishedMay 23, 2026

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @django 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-i-caught-and-fix…] indexed:0 read:3min 2026-05-23 ·