PyDepth

Where Your Django Request Time Actually Goes

Removing Django's entire default middleware chain saved 0.02 ms per request. Fixing one N+1 query on the same view saved 3.30 ms.

5 min read

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.
Share

Related posts

Arrow keys to move, Enter to open.