DEV Community

Cover image for The N+1 Query Problem in Django: How to Spot It and Kill It Before It Kills Your API
Josh Perspective
Josh Perspective

Posted on

The N+1 Query Problem in Django: How to Spot It and Kill It Before It Kills Your API

There's a specific kind of bug that doesn't look like a bug at all. Your code works. Your tests pass. Everything returns the right data. Then you deploy, real users show up, and suddenly a page that loaded instantly in development takes three seconds in production or worse, times out entirely.

Nine times out of ten, when this happens on a Django project, the culprit is the N+1 query problem. It's one of the most common performance issues in any ORM-based application, and it's especially easy to introduce without noticing, because the code that causes it often looks perfectly reasonable.

I ran into this repeatedly while working on a platform with tens of thousands of users generating posts, comments, and messages, the kind of read-heavy, relationship-heavy data model where N+1 issues hide easily and get expensive fast once real traffic shows up.

What N+1 actually means

The name describes exactly what happens: 1 query to fetch a list of objects, then N additional queries, one per object to fetch related data. If you're rendering a list of 100 blog posts and looking up each post's author separately, that's 1 query for the posts and 100 more for the authors. 101 queries to render one page.

Here's the classic example:

# views.py
def post_list(request):
    posts = Post.objects.all()
    return render(request, "posts.html", {"posts": posts})
Enter fullscreen mode Exit fullscreen mode
<!-- posts.html -->
{% for post in posts %}
  <h2>{{ post.title }}</h2>
  <p>by {{ post.author.name }}</p>
{% endfor %}
Enter fullscreen mode Exit fullscreen mode

This looks completely normal. Nothing here is "wrong" in the sense of producing incorrect output. But every time the template accesses post.author, Django fires a fresh query to fetch that author from the database, because author is a foreign key that wasn't loaded up front. With 100 posts, that's 100 extra queries invisible in the code, very visible in your database load and response time.

Spotting it before your users do

The scary part of N+1 is that it's silent in development. With 10 posts and a local SQLite database on your laptop, 11 queries execute in milliseconds and nobody notices. The problem only becomes visible at scale exactly when it's most expensive to discover.

A few ways to catch it early:

Django Debug Toolbar shows you the exact number of queries executed per page, and will explicitly flag duplicate/similar queries, which is usually the fingerprint of an N+1 pattern.

django-silk or query logging useful in staging environments where Debug Toolbar isn't installed, to log query counts per request and catch regressions before they hit production.

A simple habit: any time you loop over a queryset and access a related object or reverse relation inside that loop, stop and ask whether that relation is being loaded eagerly. This single habit catches the majority of N+1 bugs before they're even written.

Fixing it: select_related for forward relations

For foreign key and one-to-one relationships, use select_related. It performs a SQL JOIN and pulls the related object into the same query, rather than firing a separate query per row.

posts = Post.objects.select_related("author").all()
Enter fullscreen mode Exit fullscreen mode

Now post.author is already loaded when the template accesses it, no extra query per post, just the one JOIN-based query up front. This works for "forward" relationships, where the model you're querying holds the foreign key.

Fixing it: prefetch_related for reverse and many-to-many relations

For reverse foreign keys and many-to-many relationships, select_related's JOIN approach doesn't work the same way; instead, use prefetch_related, which runs a second, separate query for the related objects and joins them in Python rather than in SQL.

posts = Post.objects.prefetch_related("comments").all()
Enter fullscreen mode Exit fullscreen mode
{% for post in posts %}
  <h2>{{ post.title }}</h2>
  <p>{{ post.comments.count }} comments</p>
{% endfor %}
Enter fullscreen mode Exit fullscreen mode

This runs 2 queries total, regardless of how many posts there are: one for the posts, one for all the related comments, matched up in Python. Compare that to N+1 queries without it.

Combining both, and going deeper with Prefetch

Real-world querysets often need both at once:

posts = Post.objects.select_related("author").prefetch_related("comments", "tags")
Enter fullscreen mode Exit fullscreen mode

For more complex cases — say, you only want to prefetch approved comments, not all of them — use the Prefetch object to customize the prefetch queryset itself:

from django.db.models import Prefetch

posts = Post.objects.prefetch_related(
    Prefetch("comments", queryset=Comment.objects.filter(approved=True))
)
Enter fullscreen mode Exit fullscreen mode

This keeps the query count low while still letting you filter, order, or annotate the related data exactly as needed, useful in a comments/messaging-heavy system where you often only want a subset of related rows, not all of them.

Watch for N+1 hiding in serializers, not just templates

If you're building an API with Django REST Framework, the same problem shows up in serializers, and it's arguably easier to miss because there's no template to visually inspect.

class PostSerializer(serializers.ModelSerializer):
    author_name = serializers.CharField(source="author.name")

    class Meta:
        model = Post
        fields = ["title", "author_name"]
Enter fullscreen mode Exit fullscreen mode

If the view serving this serializer doesn't call select_related("author") on the queryset, every serialized post triggers a separate query for author.name the exact same N+1 pattern, just one layer removed from where it's easy to spot. This is especially worth checking in nested serializers, where a list endpoint returning related objects (comments, tags, likes) is a very common place for N+1 to hide in an API-first project.

A quick way to verify you've actually fixed it

Don't just assume select_related/prefetch_related fixed things verify with Django's query logging in a shell:

from django.db import connection, reset_queries
from django.conf import settings

settings.DEBUG = True  # only in a dev/test shell, never production
reset_queries()

posts = list(Post.objects.select_related("author").prefetch_related("comments"))
for post in posts:
    _ = post.author.name
    _ = post.comments.count()

print(len(connection.queries))  # should be a small, fixed number, not proportional to len(posts)
Enter fullscreen mode Exit fullscreen mode

If the query count scales with the number of posts, something's still triggering N+1 usually a relation you forgot to include in select_related/prefetch_related, or a nested relation two levels deep that needs its own prefetch.

A short checklist

  • Any loop over a queryset that accesses a related object or reverse relation is checked for eager loading
  • Forward foreign key / one-to-one access uses select_related
  • Reverse foreign key / many-to-many access uses prefetch_related
  • Complex prefetch filtering uses Prefetch objects rather than filtering in Python after the fact
  • DRF serializers are checked for the same pattern, not just templates especially nested serializers
  • Query counts are verified with Debug Toolbar or connection.queries before shipping, not just assumed fixed

N+1 queries rarely show up as an obvious bug, they show up as "the app feels slow" or "the database CPU spiked" days or weeks after a feature shipped. Catching the pattern while writing the code, rather than debugging it under production load, is a lot cheaper for the database and for you.

Top comments (0)