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

> Source: <https://dev.to/highcenburg/how-i-caught-and-fixed-an-n1-query-in-my-django-rest-api-36p5>
> Published: 2026-05-23 11:51:01+00:00

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`

— a`ForeignKey`

to`BlogSeries`

-
`tags`

— a`ManyToManyField`

to`BlogTag`

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:

``` python
@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:

-
— for`select_related()`

`ForeignKey`

and`OneToOne`

relations. Issues a SQL`JOIN`

and fetches everything in a single query. -
— for`prefetch_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`

:

```
# 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_related`

or`prefetch_related`

- The serializer accesses a related field (
`source="relation.field"`

, nested serializers,`SerializerMethodField`

that touches`obj.relation`

)

Tools that help catch this before Sentry does:

-
— shows query counts per request in the browser[django-debug-toolbar](https://github.com/jazzband/django-debug-toolbar) -
— raises exceptions in tests when N+1 queries are detected[nplusone](https://github.com/jmcarp/nplusone) -
**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.
