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 views19 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 Β· β€”