
Every Django performance discussion eventually reaches middleware. Someone proposes removing a class from the chain, someone else defends it, and nobody has a number. Here is the number, next to the one that actually matters.
The setup
A single-file project on an in-memory SQLite database, driven through Django’s test client so there is no network and no WSGI server in the measurement. Fifty authors, fifty articles, and four routes: an empty view, a list view that walks into each article’s author, that same list view rendered without the template engine, and the same list view with a join.
def naive(request):
"""One query for the articles, then one per author. The classic N+1."""
articles = Article.objects.all()[:50]
return HttpResponse(LIST_TEMPLATE.render(Context({"articles": articles})))
def naive_str(request):
"""The same N+1, rendered without the template engine."""
articles = Article.objects.all()[:50]
return HttpResponse("".join(f"{a.title} — {a.author.name}\n" for a in articles))
def joined(request):
"""One query. Same output."""
articles = Article.objects.select_related("author").all()[:50]
return HttpResponse(LIST_TEMPLATE.render(Context({"articles": articles})))
Each route is requested 300 times and the median reported, first with Django’s default
middleware chain and then with MIDDLEWARE = []. The script asserts that the
X-Frame-Options header is present in the first case and absent in the second, so the chain
is verifiably gone rather than merely configured away.
$ experiments/.venv/bin/python experiments/djangoreq/requesttime.py
python 3.13.2, django 5.1.15, sqlite in memory
median of 300 requests, 50 rows per page
route queries median
empty view 0 0.07ms
N+1 list 51 3.76ms
N+1, no template 51 3.43ms
select_related list 1 0.46ms
same 4 routes with MIDDLEWARE = []
empty view 0.05ms
N+1 list 3.72ms
N+1, no template 3.39ms
select_related list 0.44ms
What the middleware costs
Security, sessions, common, CSRF, authentication, messages and clickjacking — seven classes, each wrapping the next, each with a hook to run on the way in and on the way out. Together they cost 0.02 ms on an empty view: 0.07 ms with, 0.05 ms without.
On the list views the difference is inside the noise. The N+1 route measured 3.76 ms with the full chain and 3.72 ms without it. Deleting Django’s entire request-processing stack changed that view by about one percent.
That is not an argument that middleware is free. It is an argument that the default chain, as shipped, does very little work per request — most of it is header manipulation and lazy attribute setup. A middleware that does real work is a different matter, and the way to tell the difference is to measure the one you are worried about rather than the concept.
What the ORM costs
The same table shows the list view issuing 51 queries and taking 3.76 ms, and the identical
output with select_related("author") issuing 1 query and taking 0.46 ms. Eight times
faster, from adding one method call, on a view that was already “fast” by every wall-clock
measure anyone would have taken from the outside.
The template is where it happens. {{ a.author.name }} looks like an attribute read, and for
the first row it is — for the other forty-nine it is a SELECT against the database. Nothing
in the view says so, nothing in the template says so, and the only visible symptom is a
number nobody is looking at.
Why the template is the wrong place to look
The instinct after seeing that table is to blame the template engine, because the template is where the extra queries were issued. It is not where they came from.
A Django queryset is lazy, and a ForeignKey attribute on a model instance is a descriptor
that will fetch on first access. Between those two facts, the decision about how many queries
a page costs is made by whichever code first touches the data — and in a list view that is
usually a loop in a template, several files away from the queryset that was constructed.
The third row is that objection measured. The same fifty rows rendered with str.join
instead of the template engine issue exactly the same 51 queries, and take 3.43 ms against
3.76 ms — so the entire template engine costs 0.33 ms on this page, against the 3.30 ms the
extra queries cost. The engine is a bystander. What the template does is move the point of
evaluation somewhere the author of the view is not looking, which is why the fix belongs in
the view even though the symptom appears in the template.
The multiplier that is not in the table
The measurement above ran against SQLite in the same process. Each of those 50 extra queries cost microseconds of parsing and execution and nothing at all in transport.
Move the same code to Postgres on another host with a 0.5 ms round trip and the arithmetic
changes completely: 50 extra queries is 25 ms of pure waiting, on top of the work. The
select_related version still issues one. The ratio measured here is the floor, not the
result — the worse the network, the larger the gap.
This is why query count, rather than query time, is the metric worth alerting on. Query time is a property of the database and the machine. Query count is a property of your code, and it is the thing that gets multiplied by everything else.
What to measure instead
Pin the count in a test. assertNumQueries(1) around the view turns an N+1 regression into a
failing test rather than a slow page, and it fails at the moment the template changes rather
than a quarter later when someone opens a profiler.
For the ones already in production, CaptureQueriesContext gives the same list the toolbar
shows, without the toolbar:
with CaptureQueriesContext(connection) as captured:
client.get("/articles/")
print(len(captured))
Duplicated SQL with a different parameter each time is the signature. The fix is nearly always
select_related for a forward foreign key or prefetch_related for a reverse one.
Where to stop
Everything here is Django 5.1.15 on CPython 3.13.2 against in-memory SQLite, which is the least realistic database available and deliberately so: it removes the variable that would otherwise dominate.
Do not read the 0.02 ms middleware figure as permission to add middleware freely — read it as
a reason to stop discussing the default chain when the profile has not been opened. And do not
read the 8x as a rule about select_related, which can make things worse when the join is
wide and the rows are few. Read the query count, then decide.
Frequently asked
- Is there ever a reason to trim middleware?
- For correctness and clarity, yes — an unused middleware is a thing that can still fail. For latency, only if one of them does real work, such as a session lookup hitting the cache on every request.
- Why measure with the test client instead of a real server?
- To isolate the framework. The test client runs the same handler stack the WSGI server calls, without the network, so what is left is Django and the database. The absolute numbers are lower than production; the ratios are the point.
- How do I find N+1 queries without a benchmark?
- Assert on the count. assertNumQueries in a test pins it, and a count that changes when a template changes is the signal. Both django-debug-toolbar and CaptureQueriesContext will show you the duplicates directly.


