Introduction: Why Django N+1 Queries Kill Performance
When I first started working with Django, the N+1 queries problem felt like a hidden tax on every page I built. Everything looked fine in development, but as soon as real data and traffic hit, pages slowed down and database load spiked.
In simple terms, Django N+1 queries happen when your code runs one initial query (for a list of objects) and then silently runs an extra query for each item in that list. So instead of 1–2 efficient queries, you end up with dozens or hundreds. On a local database that might feel instant; in production, it can turn into multi-second response times.
In this step-by-step guide, I’ll show you how to spot N+1 queries in a Django app, understand why they appear in common patterns like foreign keys and related sets, and then fix them using tools like select_related and prefetch_related. By the end, you’ll be able to read your query logs, eliminate Django N+1 queries systematically, and ship pages that stay fast even as your data grows.
Prerequisites and Setup for Debugging Django N+1 Queries
To get real value from this guide, I’m assuming you already know the basics of the Django ORM: models, foreign keys, and how to use .filter(), .select_related(), and .prefetch_related() at a high level. In my own projects, once I had that foundation, tracking down Django N+1 queries became much easier because I could actually understand what each query was doing.
Before we start fixing anything, we need good visibility into what the database is doing on each request. The easiest way I’ve found is to set up Django Debug Toolbar in a local development environment.
Installing Django Debug Toolbar
First, install the package into your virtual environment:
pip install django-debug-toolbar
Then add it to your Django settings:
# settings.py
INSTALLED_APPS = [
# ...
"debug_toolbar",
]
MIDDLEWARE = [
# ...
"debug_toolbar.middleware.DebugToolbarMiddleware",
]
INTERNAL_IPS = [
"127.0.0.1",
]
Finally, include the URLs only in DEBUG mode so this tooling never leaks into production:
# urls.py
from django.conf import settings
from django.urls import include, path
urlpatterns = [
# ... your urls
]
if settings.DEBUG:
import debug_toolbar
urlpatterns += [
path("__debug__/", include(debug_toolbar.urls)),
]
With this in place, you’ll see a panel showing every query for each page, which is exactly where Django N+1 queries reveal themselves. I’ve found that spending just a few minutes exploring this panel on a slow page often exposes the root cause immediately. Django Debug Toolbar: Analyze your App Performance Stats
Step 1: Reproduce a Django N+1 Query in a View
When I’m teaching teammates about performance, I like to start by creating a slow pattern on purpose. Seeing Django N+1 queries in action once makes them much easier to recognize later in the wild.
Set up simple models that can trigger N+1
Let’s assume a very common blog-style setup: each Post has a foreign key to Author. This structure is perfect for demonstrating an N+1 problem:
# models.py
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=100)
class Post(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, on_delete=models.CASCADE)
published_at = models.DateTimeField()
In my experience, any time you loop over a list of objects and touch a related field (like post.author), you’re at high risk of triggering Django N+1 queries if you’re not careful.
Create a list view that accidentally causes N+1
Now we’ll write an intentionally inefficient view. It looks harmless, but it will hit the database once for the list of posts and then once more for each post’s author:
# views.py
from django.shortcuts import render
from .models import Post
def post_list(request):
posts = Post.objects.order_by("-published_at")[:20]
return render(request, "blog/post_list.html", {"posts": posts})
And a simple template that triggers the extra queries:
{# templates/blog/post_list.html #}
-
{% for post in posts %}
- {{ post.title }} by {{ post.author.name }} {% endfor %}
If you load this page with Django Debug Toolbar open, you’ll see one query for the Post list plus an additional query for each post.author access—that’s the classic Django N+1 queries pattern we’re going to fix in the next steps.
Once I’ve reproduced a slow page, my next move is almost always the same: ask, “Can I load these related objects in bulk?” In Django, the two workhorses for killing Django N+1 queries are select_related and prefetch_related. Picking the right one depends on the type of relationship you’re dealing with.
select_related follows single-valued relationships in a SQL JOIN. It’s ideal for ForeignKey and OneToOneField, where each object points to exactly one related row. In the earlier Post/Author example, this is exactly what we want.
Here’s how I usually fix that list view:
# views.py
from django.shortcuts import render
from .models import Post
def post_list(request):
# Load posts AND their authors in a single query
posts = (
Post.objects
.select_related("author")
.order_by("-published_at")[:20]
)
return render(request, "blog/post_list.html", {"posts": posts})
Now, when the template accesses post.author.name, Django doesn’t need extra queries; it already has author data from the initial join. In Debug Toolbar, you should now see just one query instead of 1 + N.
One thing I learned the hard way was not to overdo select_related across huge relationship chains. Joining too many big tables can slow things down, so I target only what the view really needs.
prefetch_related is built for multi-valued relationships: ManyToManyField and reverse foreign keys (e.g. author.post_set). It runs multiple queries under the hood, then stitches the data together in Python, which avoids N+1 queries while keeping SQL joins manageable.
Let’s extend the example with tags on posts:
# models.py
class Tag(models.Model):
name = models.CharField(max_length=50)
class Post(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, on_delete=models.CASCADE)
tags = models.ManyToManyField(Tag, blank=True)
If the template shows tags, a naive implementation might do this:
{# templates/blog/post_list.html #}
-
{% for post in posts %}
- {{ post.title }} by {{ post.author.name }} – Tags: {% for tag in post.tags.all %} {{ tag.name }} {% endfor %} {% endfor %}
That post.tags.all call inside the loop will trigger Django N+1 queries again, this time for tags. Here’s how I fix it in one go:
# views.py
from django.shortcuts import render
from .models import Post
def post_list(request):
posts = (
Post.objects
.select_related("author") # single-valued relation
.prefetch_related("tags") # multi-valued relation
.order_by("-published_at")[:20]
)
return render(request, "blog/post_list.html", {"posts": posts})
Now the query panel should show one query for posts (joined with authors) and one extra query for all tags linked to those posts—still just two queries total, no matter how many posts are on the page.
Deciding which tool to use in real projects
In day-to-day work, I follow a simple rule to tame Django N+1 queries quickly:
- Use
select_relatedwhen accessing a single related object (ForeignKey/OneToOne) inside a loop:post.author,profile.user,order.customer. - Use
prefetch_relatedwhen accessing a collection of related objects or reverse relations:post.tags.all(),author.post_set.all(),group.user_set.all().
When I review a slow view, I scan the template for any object.related access inside loops, then update the queryset accordingly. A quick pass like this has saved me from some nasty surprises in production. If you want to go deeper into tuning these patterns, QuerySet API reference – Django documentation is a great next step after you master the basics.
Step 3: Detect Django N+1 Queries in APIs and Templates
Once I fixed Django N+1 queries in classic views, I quickly realized the same problems were hiding in my APIs and templates. The pattern doesn’t change: you loop over a queryset, touch related fields, and suddenly you’ve got a query storm. The good news is the same debugging mindset works everywhere.
Spotting N+1 problems in Django REST Framework
In Django REST Framework (DRF), N+1 issues often come from serializers that access related objects for each item. A common example looks like this:
# serializers.py
from rest_framework import serializers
from .models import Post
class PostSerializer(serializers.ModelSerializer):
author_name = serializers.CharField(source="author.name")
class Meta:
model = Post
fields = ["id", "title", "author_name"]
# views.py (DRF viewset)
from rest_framework import viewsets
from .models import Post
from .serializers import PostSerializer
class PostViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Post.objects.all() # <-- likely N+1
serializer_class = PostSerializer
When this endpoint returns a list, DRF will access author.name for every post, causing N+1 queries unless we optimize the queryset. I usually fix it right on the viewset:
class PostViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Post.objects.select_related("author")
serializer_class = PostSerializer
Whenever I profile slow DRF endpoints, I open the query panel (or log SQL) while hitting the list endpoint and look for repeated queries that differ only by the primary key — that’s the telltale N+1 signature. How to Optimize Django REST APIs for Performance – freeCodeCamp
Finding N+1 hotspots in templates
In templates, I’ve learned to treat any {{ object.related_something }} inside a loop as suspicious. For example:
-
{% for post in posts %}
- {{ post.title }} by {{ post.author.name }} ({{ post.comments.count }}) {% endfor %}
Here, both post.author and post.comments.count can cause extra queries per item. My process is:
- Load the page with Django Debug Toolbar enabled.
- Check the SQL panel while refreshing with, say, 20–50 items.
- Look for repeated queries against the same table, differing only by an ID.
Once I see that pattern, I move the fix back into the view by adding the right select_related / prefetch_related calls. Over time this habit turned into a simple rule for me: if a template or serializer loops over objects and touches related data, I always double-check the queryset for preloading.
Quick Checklist: Best Practices for Django N+1 Queries
When I’m reviewing code or chasing a slow endpoint, I run through this quick checklist to catch Django N+1 queries before they hit production.
- Scan loops: Any time you iterate over a queryset in a view, template, or serializer, check for access to related fields inside the loop.
- Preload relationships: Use
select_related()for ForeignKey/OneToOne, andprefetch_related()for ManyToMany and reverse relations. - Use debug tools locally: Keep Django Debug Toolbar or SQL logging on in development and watch for repeated queries that differ only by primary key.
- Review DRF viewsets: Make sure
queryseton list endpoints includes the necessary preloading for serializer fields that touch related data. - Limit what you fetch: Only preload the relationships you actually use on that page or endpoint to avoid oversized joins.
- Re-test after changes: After adding
select_related/prefetch_related, reload the page and confirm query counts have dropped to a small, fixed number.
Over time, following this checklist has made spotting Django N+1 queries almost automatic for me, especially during code reviews.
Conclusion / Key Takeaways
Once I understood how Django N+1 queries worked, a lot of “mysteriously slow” pages suddenly made sense. The fix was rarely complicated; it was mostly about seeing the pattern early and reaching for the right tools.
To recap the core steps:
- Reproduce the problem: Build or identify a view, API, or template that loops over a queryset and touches related fields.
- Observe the SQL: Use Django Debug Toolbar or SQL logging to confirm you’re seeing 1 + N style queries for related data.
- Eliminate N+1: Add
select_related()for single-valued relationships andprefetch_related()for collections and reverse relations. - Apply the same logic to DRF and templates: Anywhere you serialize or render lists with related data, double-check the underlying queryset.
- Use a checklist in reviews: Make N+1 checks part of your normal code review and profiling routine.
From my experience, once these habits become second nature, N+1 issues stop being nasty surprises and turn into quick, routine fixes. From here, you can go deeper into query optimization, indexing, and caching to squeeze even more performance out of your Django apps.

Hi, I’m Cary Huang — a tech enthusiast based in Canada. I’ve spent years working with complex production systems and open-source software. Through TechBuddies.io, my team and I share practical engineering insights, curate relevant tech news, and recommend useful tools and products to help developers learn and work more effectively.





