Django Β· Backend Performance
From N+1 to Sub-100ms: Profiling and Killing Hidden Query Costs in Django
When the Centralized Gate Pass System started handling real traffic, a dashboard that felt instant in development began crawling. The page wasn't doing anything obviously expensive β it just listed passes and the gates they belonged to. The culprit was the oldest performance bug in the ORM book: the N+1 query.
## What an N+1 query actually is
You fetch a list of N objects in one query, then touch a related object on each one inside a loop. Django dutifully fires one more query per row. A list of 200 passes that each reads `pass.gate.name` becomes 1 + 200 = 201 queries. Each is fast in isolation; together they bury you in round-trips.
```
# The trap: one query for passes, then one per pass for the gate
for p in GatePass.objects.all():
print(p.gate.name) # <-- a fresh SELECT every iteration
```
## Step 1: make the invisible visible
You cannot fix what you cannot measure. I install django-debug-toolbar in development and watch the SQL panel β it shows the query count and flags duplicates outright. In tests and CI I assert the count directly:
```
from django.test.utils import CaptureQueriesContext
from django.db import connection
with CaptureQueriesContext(connection) as ctx:
response = client.get("/passes/")
assert len(ctx.captured_queries) < 10
```
That assertion is the real fix β it stops the regression from ever coming back.
## Step 2: select_related vs prefetch_related
The two tools solve two different shapes of relationship.
`select_related` follows forward ForeignKey and OneToOne links with a SQL JOIN, pulling everything in a single query. Use it for "many passes, each with one gate."
`prefetch_related` handles reverse and ManyToMany links by running a second query and stitching the results together in Python. Use it for "one gate with many passes."
```
# 201 queries -> 1
passes = GatePass.objects.select_related("gate")
# For the reverse side, fetch passes for all gates in 2 queries total
gates = Gate.objects.prefetch_related("passes")
```
## Step 3: stop fetching what you do not render
The list view didn't need every column or every relation. `only()` and `values()` trim the payload; `annotate()` pushes counts and sums into the database instead of looping in Python.
```
GatePass.objects.select_related("gate").only("code", "issued_at", "gate__name")
Gate.objects.annotate(active=Count("passes", filter=Q(passes__active=True)))
```
## The result
The dashboard dropped from ~210 queries to 3, and median response time fell from roughly 900ms to under 100ms β without touching the front-end. The lesson I keep relearning: most "slow Django" isn't slow Python or a slow database. It's a fast database asked the same question 200 times.