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

- Published: 2026-08-19
- Tags: frameworks, performance, tooling
- Source: https://pydepth.com/blog/where-django-request-time-goes/
- Language: en-US
- Author: Elliot Sayer

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

```python
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:

```python
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.
