Diagnosing and Fixing N+1 Queries in Django with select_related & prefetch_related
A step‑by‑step guide to spotting N+1 query patterns, measuring them, and applying select_related or prefetch_related to reduce query counts in Django views and templates.
21 Aug 2025, 11:29 UTC

Problem: A slow page that keeps firing dozens of SQL queries
When a Django view or API endpoint takes longer than expected, the first clue is often a high number of SQL statements logged by the database or displayed in the Django Debug Toolbar. If you see 20‑30 or more queries for a single request, you might be dealing with an N+1 query pattern—the ORM lazily loads related objects, causing a new SELECT for each item in a loop.
Recognizing the Condition
Typical symptoms:
- Page or API response takes > 1 s on a small dataset.
- Debug Toolbar shows repetitive queries with the same structure but different primary‑key values.
- Connection.queries log contains a long list of identical statements.
- Template loops over a related manager or reverse foreign key without prefetching.
Diagnostic Table
| Check | What to Look For |
|---|---|
| Query count | >10 queries for a single view |
| Query pattern | Same SELECT with different IDs |
| Template access | Looping over related objects |
| QuerySet chain | No select_related/prefetch_related |
Ordered Checks
- Enable diagnostics – Add
DEBUG_TOOLBAR_CONFIG = {'SHOW_TOOLBAR_CALLBACK': lambda request: True}and restart. Or, in a test, useconnection.queries.- Run the view and note the query count.
- Inspect template loops – Look for
{% for child in parent.children.all %}or similar. Verify if the related manager is accessed inside a loop. - Check the queryset – In the view, print
repr(queryset.query)to see the generated SQL. If it containsSELECT * FROM child WHERE parent_id = …inside a loop, you have an N+1. - Run a unit test – Use
django.test.TestCaseto count queries before and after applying optimizations.
Practical Fixes
Once you know where the N+1 originates, choose the appropriate optimizer:
- ForeignKey/OneToOne – Use
.select_related('field_name')to perform a JOIN and fetch the related object in the same query. - ManyToMany or reverse FK – Use
.prefetch_related('field_name'); Django runs a separate SELECT for the related set and merges it in Python. - Template logic – Avoid nested loops over related objects; instead, annotate or prefetch once and iterate over the prefetched list.
- Annotations – Apply
.annotate()on the base queryset, not inside a loop.
Example Before & After
# views.py
from django.shortcuts import render
from .models import Author
def author_list(request):
authors = Author.objects.all() # No prefetch
return render(request, 'authors.html', {'authors': authors})
Template authors.html:
{% for author in authors %}
{{ author.name }}
{% for book in author.books.all %}
{{ book.title }}
{% endfor %}
{% endfor %}
After optimization:
# views.py
authors = Author.objects.prefetch_related('books').all()
Now the SQL consists of only two queries: one for authors and one for all related books.
Verification Steps
- Run Debug Toolbar – The query count should drop to
2(or1if no related fields are accessed). - Unit test assertion –
from django.test import TestCase from django.db import connection class AuthorViewTest(TestCase): def test_query_count(self): with self.assertNumQueries(2): self.client.get('/authors/') - Benchmark load time – Use
time.perf_counteraroundself.client.getand compare before/after. Expect a noticeable drop.
Limitations & Cautions
- Overusing
select_relatedcan produce very large JOINs, consuming memory and slowing the database. Use it only for small related objects that are actually needed. - Large
prefetch_relatedsets still load all rows into Python memory. If the related table is huge, consider pagination or custom annotations. - Many‑to‑many through tables with millions of rows can make prefetching impractical; in such cases, raw SQL or database views may be preferable.
- Chaining multiple
prefetch_relatedcalls can increase the number of separate queries. Profile before committing.
Escalation Criteria
- If the query count remains >10 after applying optimizations, inspect the generated SQL for unintended JOINs or subqueries.
- When memory usage spikes or DB connections time out, add indexes on foreign key or many‑to‑many fields used in the filter.
- For persistent performance issues, consider caching the queryset with Django’s low‑level cache API or a third‑party cache like Redis.
- As a last resort, rewrite complex logic in raw SQL or create a database view that pre‑joins the necessary tables.
Conclusion
Detecting N+1 queries is straightforward with Django’s debugging tools. Once identified, applying select_related or prefetch_related often reduces query counts from dozens to just a couple, dramatically improving response times. Always verify with the Debug Toolbar, unit tests, and performance benchmarks, and be mindful of the trade‑offs between JOIN size and prefetch memory usage.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.