Middleware
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python
3.12-3.14 and PostgreSQL 15 or newer, all checked on 2026-10-03; where the 5.2 LTS (5.2.17) behaves differently the
text says so. Every example was run on that stack in a scratch project on SQLite with the Django test client, the
runserver development server and uvicorn; none of them needs PostgreSQL or Redis (the site-wide cache example uses
the in-process LocMemCache, and the text says what changes with a Redis cache). Middleware is the part of Django that
sees every request on the way in and every response on the way out, before and after the URL resolver and the view:
sessions, authentication, CSRF protection, HTTPS redirects, compression and security headers are all middleware, and so
is whatever cross-cutting behaviour the Bookshelf application needs (a request id on every log line, a maintenance
switch, a Server-Timing header). This page explains the model (an onion of callables), how to write middleware as a
function or a class, the three optional hooks, what happens to exceptions and streaming responses, how sync and async
middleware coexist, how ordering works, and what each of the built-in middleware classes does and where it belongs in
the list.
In a hurry: what goes where
| Need | Rule of thumb | Section |
|---|---|---|
Run code around every request |
A middleware factory: |
|
Do setup once |
In the factory body or |
|
Skip a middleware at start-up |
Raise |
|
Answer without calling the view |
Return a response without calling |
|
Act on the resolved view or on its exceptions |
|
The three hooks: process_view, process_exception and process_template_response |
Work with streamed bodies |
Check |
|
Support ASGI without penalty |
Set |
|
Order the list |
Top-down on the way in, bottom-up on the way out; follow the documented ordering hints. |
|
Use a built-in |
Add the dotted path to |
|
Test it |
|
The onion: how a request travels through the stack
MIDDLEWARE is an ordered list of dotted paths to middleware factories. When the server starts, the
handler (WSGIHandler or ASGIHandler) builds the chain once: it wraps the innermost callable (which resolves the URL
and calls the view) in the last factory of the list, wraps that in the next-to-last factory, and so on, so the first item
of the list ends up as the outermost layer. A request therefore passes through the factories top-down, reaches the view
at the core of the onion and the response then passes back out through the same layers in reverse order.
Three properties follow from that structure, and most middleware bugs are violations of one of them.
-
Symmetry. The layers a response passes through on the way out are exactly the layers that saw the request on the way in. If a layer returns a response without calling
get_response, none of the layers inside it, including the view, run, and the response travels back out only through the layers above it. -
Every layer gets a response back. Django converts exceptions raised by the view or by a middleware into an HTTP response (a 404 for
Http404, a 403 forPermissionDenied, a 500 for anything else) between each pair of layers, so a middleware that callsget_responsereceives a response object, never the exception. -
get_responseis whatever comes next. For the last middleware it is not the bare view but a wrapper from the handler that applies the view hooks, calls the view with the URL arguments, and applies the exception and template-response hooks of every middleware.
Source: Middleware order and layering
The Bookshelf middleware stack
django-admin startproject generates this list, and it is the baseline of the Bookshelf application (the catalog app
built across this section). Each entry is documented in Built-in middleware reference.
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
Source: Activating middleware
The list may be empty (Django does not require any middleware) but the documentation strongly suggests keeping at least
CommonMiddleware. Most of the remainder of this page extends the list with the three custom middleware that the
Bookshelf writes below (RequestIdMiddleware, request_timing, server_header) and shows the order in which they
should be placed. The settings module itself is covered on
Settings & Configuration.
Writing middleware
A middleware factory is a callable that takes a get_response callable and returns a middleware. A middleware is a
callable that takes a request and returns a response, exactly like a view. That contract can be met in two ways, with the
same behaviour.
As a function
The outer function is the factory: it runs once and may keep state in the closure. The inner function is the middleware:
it runs for each request, and everything before get_response(request) happens on the way in, everything after on the
way out.
import logging
import time
from django.conf import settings
logger = logging.getLogger("catalog.middleware")
def request_timing(get_response):
slow_ms = getattr(settings, "CATALOG_SLOW_REQUEST_MS", 500) # read once, at start-up
def middleware(request):
started = time.perf_counter()
response = get_response(request)
elapsed_ms = (time.perf_counter() - started) * 1000
response["Server-Timing"] = f"app;dur={elapsed_ms:.1f}"
if elapsed_ms > slow_ms:
logger.warning("slow request %s %s: %.0f ms", request.method, request.path, elapsed_ms)
return response
return middleware
Source: Writing your own middleware
Enabled with "catalog.middleware.request_timing", the test client sees Server-Timing: app;dur=0.4 (the value varies);
curl -i against runserver shows it too:
curl -si http://127.0.0.1:8000/books/ | grep -i server-timing
Server-Timing: app;dur=0.1
Source: Writing your own middleware
As a class
A class whose instances are callable is the other form. init(self, get_response) is the factory and
call(self, request) is the middleware, which makes per-instance state natural and is the only way to add the three
hooks of The three hooks: process_view, process_exception and process_template_response.
import uuid
class RequestIdMiddleware:
header = "X-Request-ID"
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
incoming = request.headers.get(self.header, "")
try:
request_id = str(uuid.UUID(incoming)) # trust only a well-formed value
except ValueError:
request_id = str(uuid.uuid4())
request.request_id = request_id
response = self.get_response(request)
response[self.header] = request_id
return response
Source: Writing your own middleware
The incoming header is validated before it is reused: a client-supplied value that ends up in a log line or a response header is untrusted input (a value containing a newline is the classic way to forge log entries), so the middleware accepts a UUID and replaces anything else. The test below passes a valid id, an invalid one and none:
from django.test import TestCase, override_settings
BASE = ["django.middleware.common.CommonMiddleware"]
class RequestIdTests(TestCase):
@override_settings(MIDDLEWARE=BASE + ["catalog.middleware.RequestIdMiddleware"])
def test_request_id(self):
r = self.client.get("/whoami/")
self.assertEqual(r.json()["request_id"], r["X-Request-ID"]) # the view saw the same id
given = "12345678-1234-5678-1234-567812345678"
r = self.client.get("/whoami/", headers={"X-Request-ID": given})
self.assertEqual(r["X-Request-ID"], given)
r = self.client.get("/whoami/", headers={"X-Request-ID": "x\r\nbad"})
self.assertNotEqual(r["X-Request-ID"], "x")
Source: Overriding settings
Function or class? They are equivalent for request/response work. Choose a class when the middleware has several
collaborating methods, configuration through class attributes or subclasses (the built-in CommonMiddleware,
GZipMiddleware and LocaleMiddleware expose attributes meant to be overridden), or hooks; choose a function for a
short wrapper. Middleware can live anywhere on the Python path, and the entry in MIDDLEWARE is the dotted path to the
class or function.
One-time setup, init and MiddlewareNotUsed
Django builds the chain with only the get_response argument, so init cannot require other arguments. It runs
once, when the web server starts and the handler loads its middleware (the test client builds its handler lazily, on its
first request), not once per request; per-request state belongs in call or on the request, never on self, because one
instance serves every request, and under a threaded or async server it serves many requests at once.
A middleware can also decide at start-up that it is not needed. Raising MiddlewareNotUsed from init removes it
from the chain and, when DEBUG is True, logs a debug message to the django.request logger.
from django.conf import settings
from django.core.exceptions import MiddlewareNotUsed
from django.http import HttpResponse
class MaintenanceModeMiddleware:
def __init__(self, get_response):
if not getattr(settings, "CATALOG_MAINTENANCE", False):
raise MiddlewareNotUsed("maintenance mode is off") # removed from the chain
self.get_response = get_response
def __call__(self, request):
if request.path == "/healthz/":
return self.get_response(request) # health checks keep working
return HttpResponse(
"The catalogue is being re-indexed.",
status=503,
headers={"Retry-After": "120"},
)
Source: Marking middleware as unused
Short-circuiting
MaintenanceModeMiddleware shows the second great use of middleware: returning a response without calling
get_response. Because of the symmetry rule, the layers inside it (and the URL resolver and the view) are skipped, and
the layers above it still see the response on its way out, so a SecurityMiddleware above it still adds its headers to
the 503. The test below proves that the middleware is a no-op with the flag off and answers 503 (except for the health
check) with the flag on; the tracing middleware of Ordering prove which layers are skipped:
from django.test import Client, TestCase, override_settings
MM = "catalog.middleware.MaintenanceModeMiddleware"
class MaintenanceTests(TestCase):
def test_maintenance(self):
with override_settings(MIDDLEWARE=BASE + [MM]): # flag off: MiddlewareNotUsed
self.assertEqual(Client().get("/books/").status_code, 200)
with override_settings(MIDDLEWARE=BASE + [MM], CATALOG_MAINTENANCE=True):
r = Client().get("/books/") # a new Client builds a new chain
self.assertEqual((r.status_code, r["Retry-After"]), (503, "120"))
self.assertEqual(Client().get("/healthz/").status_code, 200)
Source: Middleware order and layering
Note the Client() created inside each override_settings: the chain is built once per handler, so a client created
earlier would keep the middleware it was built with. The same rule explains why changing MIDDLEWARE in production
requires a restart.
The three hooks: process_view, process_exception and process_template_response
A class-based middleware may define up to three extra methods. Django looks for them when it builds the chain and calls them at fixed points of the request cycle; function middleware cannot have them. All three are optional and independent.
| Hook | Called | May return | Order across middleware |
|---|---|---|---|
|
Just before Django calls the view, after URL resolution and after every middleware’s |
|
Top-down, in |
|
When the view raises (or when rendering a |
|
Bottom-up; the first one to return a response stops the rest. |
|
After the view returns, only if the response has a |
A response object with a |
Bottom-up. |
process_view
request is the HttpRequest; view_func is the actual function Django is about to call (for a class-based view, the
function returned by as_view()); view_args and view_kwargs are the positional and keyword arguments the URL
resolver extracted, without the leading request. Returning None continues with the other process_view hooks and then
the view; returning an HttpResponse skips the view and goes straight to the response phase. It is the right place for
behaviour that depends on which view will run (attaching the view name for logging, enforcing a rule on views carrying
a marker attribute), which call cannot know yet, because the URL has not been resolved when call starts.
process_exception
Django calls it when a view raises an exception. Return None to fall back to default handling (the exception becomes a
404, 403 or 500, goes to the error reporting and logging configuration, and the debug page shows when DEBUG is on), or
return a response to take over: the template-response and response middleware then still run. It sees only exceptions
raised by the view (or while rendering its TemplateResponse); exceptions raised by a middleware are converted to a
response and handed to the next layer instead.
process_template_response
Called just after the view returns, but only when the response has a render() method. It must return a response that
also has render(); it may mutate response.template_name and response.context_data or return a new
TemplateResponse. Do not render the response yourself: Django renders it once, after every template-response hook has
run, which is the reason TemplateResponse exists (a decorator or middleware can still change the template or context
after the view returned).
from django.http import HttpResponse, JsonResponse
class CatalogHooksMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
return self.get_response(request)
def process_view(self, request, view_func, view_args, view_kwargs):
request.view_name = f"{view_func.__module__}.{view_func.__name__}"
if view_kwargs.get("pk") == 13:
return HttpResponse("Unlucky book", status=403) # skip the view
return None # continue
def process_exception(self, request, exception):
if isinstance(exception, ValueError):
return JsonResponse({"error": str(exception)}, status=500)
return None # default handling
def process_template_response(self, request, response):
response.context_data = {**(response.context_data or {}), "banner": "Summer reading list"}
return response
Source: Other middleware hooks
The views behind it are a TemplateResponse view for book_detail, a view that always raises ValueError, and a plain
404 for ids above 100:
from django.http import Http404, HttpResponse
from django.template.response import TemplateResponse
def book_detail(request, pk):
if pk > 100:
raise Http404("No such book")
return TemplateResponse(request, "catalog/book_detail.html", {"title": f"Book {pk}"})
def broken(request):
raise ValueError("catalogue exploded")
<h1>{{ title }}</h1>
{% if banner %}<p class="banner">{{ banner }}</p>{% endif %}
class HookTests(TestCase):
@override_settings(MIDDLEWARE=BASE + ["catalog.middleware.CatalogHooksMiddleware"])
def test_hooks(self):
self.assertContains(self.client.get("/books/7/"), "Summer reading list") # template hook
self.assertEqual(self.client.get("/books/13/").status_code, 403) # process_view short-circuit
r = self.client.get("/broken/") # process_exception
self.assertEqual((r.status_code, r.json()), (500, {"error": "catalogue exploded"}))
self.assertEqual(self.client.get("/books/101/").status_code, 404) # None: default handling
Two cautions from the documentation. Reading request.POST in a middleware before the view runs, or inside
process_view, parses the request body early and prevents the view from changing the upload handlers; CsrfViewMiddleware
is the one exception, because it offers the csrf_exempt and csrf_protect decorators that let a view choose when the
check runs. And do not do work in process_view that you can do in call: a hook is only needed when you need the
resolved view.
What a middleware sees when something is raised
Django wraps the call between each pair of layers in the exception-to-response conversion (the documentation calls it
the thin film between the layers of the onion). An Http404 raised by the view, by a later middleware, or by the URL
resolver becomes a 404 response before it reaches your layer; your get_response call returns that response and you do
not need try/except around it.
class ExceptionConversionTests(TestCase):
@override_settings(MIDDLEWARE=["catalog.middleware.tracer_a"])
def test_exceptions_become_responses(self):
r = self.client.get("/books/101/") # the view raises Http404
self.assertEqual(r.status_code, 404) # tracer_a received a 404 response and passed it on
Source: Exception handling
Consequences: a middleware that wants to react to errors inspects response.status_code on the way out (or implements
process_exception for exceptions raised by the view itself); a middleware that raises (PermissionDenied, Http404) is
converted to a response and the layers above it see that response, so raising from middleware is safe and idiomatic; and
no layer is ever skipped because another one raised. Setting DEBUG_PROPAGATE_EXCEPTIONS = True skips the
conversion and lets exceptions propagate upward. (The test client has its own switch, raise_request_exception, which
is on by default so that an unhandled exception in a view fails the test with its traceback.)
Streaming responses
An HttpResponse has a content attribute; a StreamingHttpResponse does not, it has streaming_content, an iterator
of chunks that Django consumes while it sends the response. A middleware that touches the body must therefore check
response.streaming first. The streamed content must be assumed too large to hold in memory: a response middleware may
wrap it in a new generator, but must not consume it. Since Django 4.2 a StreamingHttpResponse can be built from an
async iterator when served over ASGI, and the wrapper has to match the iterator, so check response.is_async.
import logging
logger = logging.getLogger("catalog.middleware")
def count_bytes(get_response):
def wrap_sync(content, counter):
for chunk in content:
counter["bytes"] += len(chunk)
yield chunk
logger.info("streamed %d bytes", counter["bytes"])
async def wrap_async(content, counter):
async for chunk in content:
counter["bytes"] += len(chunk)
yield chunk
logger.info("streamed %d bytes", counter["bytes"])
def middleware(request):
response = get_response(request)
if response.streaming:
counter = {"bytes": 0}
if response.is_async:
response.streaming_content = wrap_async(response.streaming_content, counter)
else:
response.streaming_content = wrap_sync(response.streaming_content, counter)
response["X-Streamed"] = "yes"
return response
return middleware
The wrapper yields every chunk unchanged and counts as it goes; the final log line is written when the client has
received the last chunk, not when the view returned. Both branches were run, with a synchronous generator view and an
async generator view:
from django.test import AsyncClient
class StreamingTests(TestCase):
@override_settings(MIDDLEWARE=BASE + ["catalog.middleware.count_bytes"])
def test_streaming_sync(self):
r = self.client.get("/stream/")
self.assertEqual(r["X-Streamed"], "yes")
self.assertEqual(b"".join(r.streaming_content), b"chunk 0\nchunk 1\nchunk 2\n")
@override_settings(MIDDLEWARE=BASE + ["catalog.middleware.count_bytes"])
async def test_streaming_async(self):
r = await AsyncClient().get("/astream/")
chunks = [c async for c in r.streaming_content]
self.assertEqual(b"".join(chunks), b"achunk 0\nachunk 1\nachunk 2\n")
Source: Dealing with streaming responses
Built-in middleware that need the body handle this for you: GZipMiddleware compresses streaming content chunk by chunk,
and CommonMiddleware only sets Content-Length on non-streaming responses.
Sync and async middleware
Django can serve the same project over WSGI (synchronous) and ASGI (asynchronous). Middleware can support only synchronous Python (the default), only asynchronous Python, or both; Django adapts calls to fit a middleware that cannot take the kind it is given, at a performance cost (a thread hop or an event-loop hop at each boundary). Two class or function attributes advertise what a middleware supports:
| Attribute | Default | Meaning |
|---|---|---|
|
|
The middleware can handle synchronous requests. |
|
|
The middleware can handle asynchronous requests. |
When both are True Django passes the request without converting it, and the middleware finds out which kind it got by
testing whether get_response is a coroutine function with inspect.iscoroutinefunction. Three decorators in
django.utils.decorators set the flags on a middleware function: sync_only_middleware, async_only_middleware and
sync_and_async_middleware. The returned callable must match the nature of get_response: with an async get_response
return a coroutine function.
from inspect import iscoroutinefunction
from django.utils.decorators import sync_and_async_middleware
@sync_and_async_middleware
def server_header(get_response):
if iscoroutinefunction(get_response):
async def middleware(request):
response = await get_response(request)
response["X-Served-By"] = "catalog-async"
return response
else:
def middleware(request):
response = get_response(request)
response["X-Served-By"] = "catalog-sync"
return response
return middleware
Source: Asynchronous support
A class middleware sets the two attributes as class attributes, and an async class middleware must mark its instances
as coroutine functions, otherwise Django cannot tell that calling the instance returns a coroutine. markcoroutinefunction
is in the standard library’s inspect module (Python 3.12+, which is every Python Django 6.1 supports; on older Django
releases and Python versions the same function was imported from asgiref.sync).
from inspect import iscoroutinefunction, markcoroutinefunction
class AsyncAuditMiddleware:
async_capable = True
sync_capable = False
def __init__(self, get_response):
self.get_response = get_response
if iscoroutinefunction(self.get_response):
markcoroutinefunction(self)
async def __call__(self, request):
response = await self.get_response(request)
response["X-Audit"] = "async"
return response
Source: Asynchronous support
class AsyncMiddlewareTests(TestCase):
@override_settings(MIDDLEWARE=BASE + ["catalog.middleware.server_header"])
def test_hybrid_sync(self):
self.assertEqual(self.client.get("/async/")["X-Served-By"], "catalog-sync")
@override_settings(MIDDLEWARE=BASE + ["catalog.middleware.server_header"])
async def test_hybrid_async(self):
r = await AsyncClient().get("/async/")
self.assertEqual(r["X-Served-By"], "catalog-async")
@override_settings(MIDDLEWARE=BASE + ["catalog.middleware.AsyncAuditMiddleware"])
async def test_async_only(self):
r = await AsyncClient().get("/async/")
self.assertEqual(r["X-Audit"], "async")
@override_settings(MIDDLEWARE=BASE + ["catalog.middleware.AsyncAuditMiddleware"])
def test_async_only_under_sync(self):
self.assertEqual(self.client.get("/books/")["X-Audit"], "async") # Django adapts it
Source: Asynchronous support and Asynchronous support: views
Rules worth remembering:
-
A middleware sees the mode the chain gives it, not the mode of the view. Django optimizes the call stack to have as few sync/async transitions as possible. A hybrid middleware wrapping an
asyncview can still be called in sync mode if a synchronous-only middleware sits between it and the view. -
Each mode change costs a hop. Under ASGI, Django adapts the call wherever a synchronous-only middleware meets the async handler; with
DEBUGlogging ondjango.requestthe adaptation is logged, for exampleAsynchronous handler adapted for middleware catalog.middleware.request_timing.when the scratch project’s chain ran underuvicorn. The fix is to make your own middleware hybrid, not to avoid ASGI. -
The hooks adapt individually.
process_view,process_template_responseandprocess_exceptionshould match the sync/async mode of the middleware, but Django adapts them one by one (again at a cost) if they do not. -
Do not call the sync ORM inside an async middleware. Use the
a-prefixed methods (await Book.objects.acount()) orsync_to_async; the async-unsafe check raisesSynchronousOnlyOperationotherwise. -
Built-in middleware are all both-capable, so nothing in
django.*forces a hop.
Async views and the ORM from async code are covered in detail on Making Queries (the sync and async section) and the async support page of this section.
MiddlewareMixin and old-style middleware
Before Django 1.10 middleware were classes with process_request and process_response methods, listed in the
MIDDLEWARE_CLASSES setting; the new MIDDLEWARE style arrived in 1.10 and MIDDLEWARE_CLASSES was removed in 2.0, so a
tutorial that sets MIDDLEWARE_CLASSES simply does nothing in a current project. django.utils.deprecation.MiddlewareMixin
still exists to port that style with minimal change: it supplies init(get_response) (the argument has been
required, and None no longer accepted, since 4.0) and a call that calls process_request (if defined), then
get_response, then process_response (if defined), and it supports both synchronous and asynchronous requests.
from django.utils.deprecation import MiddlewareMixin
class LegacyHeaderMiddleware(MiddlewareMixin):
def process_request(self, request):
request.legacy = True
def process_response(self, request, response):
response["X-Legacy"] = "1"
return response
The semantics differ from the old style in three ways that matter: if a process_request returns a response, only that
middleware and those above it see the response (the old style called every process_response anyway); process_exception
applies only to exceptions raised by the view, not by other middleware; and a middleware that raises is converted into a
response and passed to the next layer instead of skipping all earlier process_response calls. For new code,
write a plain function or class and keep the mixin for ported code and for the built-ins that subclass it.
Ordering
The order of MIDDLEWARE matters because middleware depend on each other: AuthenticationMiddleware reads the session
that SessionMiddleware attached, so it must come after it. The practical way to see and test an ordering is a pair of
tracing middleware that record when each layer is entered and left:
TRACE = []
def make_tracer(name):
def factory(get_response):
def middleware(request):
TRACE.append(f"{name}:in")
response = get_response(request)
TRACE.append(f"{name}:out")
return response
return middleware
return factory
tracer_a = make_tracer("A")
tracer_b = make_tracer("B")
from catalog import middleware as mw
class OrderTests(TestCase):
@override_settings(MIDDLEWARE=["catalog.middleware.tracer_a", "catalog.middleware.tracer_b"])
def test_order(self):
mw.TRACE.clear()
self.client.get("/books/")
self.assertEqual(mw.TRACE, ["A:in", "B:in", "B:out", "A:out"])
@override_settings(
MIDDLEWARE=["catalog.middleware.tracer_a", "catalog.middleware.MaintenanceModeMiddleware", "catalog.middleware.tracer_b"],
CATALOG_MAINTENANCE=True,
)
def test_short_circuit(self):
mw.TRACE.clear()
Client().get("/books/")
self.assertEqual(mw.TRACE, ["A:in", "A:out"]) # B and the view never ran
Source: Middleware order and layering
The built-in middleware have documented ordering hints. They follow from what each one reads or writes on the way in and on the way out:
| Middleware | Where to put it |
|---|---|
|
Near the top if you turn on the SSL redirect, so the redirect avoids running through other middleware. |
|
Before those that modify the |
|
Before any middleware that may change or use the response body; after |
|
Before any middleware that may raise an exception to trigger an error view (such as |
|
Before any middleware that may change the response (it sets the |
|
One of the topmost, after |
|
Before any middleware that may change the response (it sets |
|
Before any view middleware that assumes CSRF attacks were dealt with; before |
|
After |
|
After |
|
After |
|
After any middleware that modifies the |
|
Can be near the bottom. The |
|
Near the bottom: last-resort middleware that only act when the response is a 404. |
Source: Middleware ordering
Ordering is also the main thing to review when two middleware both touch the response. A concrete way to reason about
it: draw the list, then for each response header ask "who sets it, who reads it, which one is outer?". The
Content-Length set by CommonMiddleware is wrong if a middleware outside it appends to the body afterwards; the ETag of
ConditionalGetMiddleware is computed on whatever the response body is at that moment, which is why it sits outside
GZipMiddleware; the cache key chosen by FetchFromCacheMiddleware depends on Vary, which is why it sits inside the
middleware that add to Vary.
A complete, ordered Bookshelf stack
The Bookshelf ends up with the list below: the Django baseline plus compression and the three custom middleware.
RequestIdMiddleware is outermost of the custom ones so that every other layer and the view (and its log lines) can use
request.request_id; request_timing sits inside it so the timing excludes only the id bookkeeping; server_header is
hybrid, so it does not force an adaptation under ASGI.
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.middleware.gzip.GZipMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"catalog.middleware.RequestIdMiddleware",
"catalog.middleware.request_timing",
"catalog.middleware.server_header",
]
Running the project with manage.py check reports no issues, and the response of the development server carries the
effects of every layer at once:
curl -si -H "Accept-Encoding: gzip" http://127.0.0.1:8000/whoami/
HTTP/1.1 200 OK
Content-Type: application/json
X-Served-By: catalog-sync
Server-Timing: app;dur=0.4
X-Request-ID: 3aca8641-4538-442d-b79e-55bbc09fcbdd
X-Frame-Options: DENY
Content-Length: 85
Vary: Cookie
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin
(The Date and Server headers are omitted; the id and timing differ on every request. GZipMiddleware did not compress
this 85-byte body because bodies under 200 bytes are left alone, and X-Served-By: catalog-sync shows the WSGI chain.)
Under uvicorn config.asgi:application the same chain answers X-Served-By: catalog-async.
Source: Middleware ordering
Built-in middleware reference
Every class below lives in the django.middleware or a django.contrib package, is documented on the
built-in middleware reference, is both sync- and async-capable, and is enabled by its dotted path in MIDDLEWARE.
| Class | Path | In one line |
|---|---|---|
|
|
HTTPS redirect, HSTS, |
|
|
Loads the session into |
|
|
|
|
|
Emails |
|
|
Checks the CSRF token on unsafe methods. |
|
|
Adds |
|
|
Redirects every anonymous request to the login page unless the view opts out (5.1). |
|
|
Authenticates from a web-server-provided header or variable. |
|
|
Cookie- and session-based flash messages. |
|
|
|
|
|
|
|
|
Compresses responses for clients that accept gzip. |
|
|
|
|
|
Chooses the language from the request. |
|
|
The site-wide cache. |
|
|
Adds |
|
|
Serves a flat page when the URL resolves to a 404. |
|
|
Redirects stored in the database when the URL resolves to a 404. |
Source: Available middleware
SecurityMiddleware
SecurityMiddleware provides several independent protections, each controlled by a setting. The documentation advises
that, when the deployment allows it, the front-end web server perform them instead, so that requests that never reach
Django (static files, uploads served by the proxy) get the same protections.
| Setting | Effect |
|---|---|
|
Permanently redirects (301) HTTP requests to HTTPS; |
|
Non-zero adds |
|
Adds |
|
Sets |
|
Sets |
from django.test import TestCase, override_settings
DEFAULT = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
class SecurityTests(TestCase):
@override_settings(
MIDDLEWARE=DEFAULT,
SECURE_HSTS_SECONDS=3600,
SECURE_HSTS_INCLUDE_SUBDOMAINS=True,
SECURE_CONTENT_TYPE_NOSNIFF=True,
SECURE_REFERRER_POLICY="same-origin",
SECURE_SSL_REDIRECT=True,
SECURE_REDIRECT_EXEMPT=[r"^healthz/$"],
)
def test_security_headers(self):
r = self.client.get("/books/")
self.assertEqual((r.status_code, r["Location"]), (301, "https://testserver/books/"))
r = self.client.get("/books/", secure=True)
self.assertEqual(r["Strict-Transport-Security"], "max-age=3600; includeSubDomains")
self.assertEqual(r["X-Content-Type-Options"], "nosniff")
self.assertEqual(r["Referrer-Policy"], "same-origin")
self.assertEqual(r["Cross-Origin-Opener-Policy"], "same-origin")
self.assertEqual(r["X-Frame-Options"], "DENY")
self.assertEqual(self.client.get("/healthz/").status_code, 200) # exempt from the redirect
Source: Security middleware
The setting names live in the settings reference, and the whole deployment checklist (manage.py check --deploy) is the
subject of the settings and deployment pages of this section. Two historical notes: SECURE_BROWSER_XSS_FILTER and the
X-XSS-Protection header were removed in 4.0 because current browsers ignore the header (use a Content Security
Policy instead), and the Cross-Origin-Opener-Policy header appeared in 4.0.
SessionMiddleware, AuthenticationMiddleware and the authentication family
SessionMiddleware enables session support and request.session; sessions are covered on the sessions page of this
section. AuthenticationMiddleware adds request.user to every request, as a lazy object that touches the session (and
the database) only when something reads it; since 5.0 it also adds await request.auser() for async code. It requires
SessionMiddleware before it. Reading request.user in a custom middleware placed after it works; placed before it,
the attribute does not exist yet.
LoginRequiredMiddleware
Added in 5.1, LoginRequiredMiddleware redirects every unauthenticated request to the login page, except for views
marked with login_not_required. It must come after AuthenticationMiddleware. The login URL is settings.LOGIN_URL by
default, or the login_url given to a login_required decorator on the view; the redirect query parameter is next, or
the redirect_field_name of the decorator. Subclass the middleware to change redirect_field_name, get_login_url()
or get_redirect_field_name() (return None from the latter to add no query parameter).
MIDDLEWARE = [
# ...
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.auth.middleware.LoginRequiredMiddleware",
# ...
]
LOGIN_URL = "/accounts/login/"
from django.contrib.auth.decorators import login_not_required
from django.urls import path
from . import views
urlpatterns = [
path("books/", login_not_required(views.book_list)), # public catalogue
path("members/", views.secret_page), # needs a login
]
from django.contrib.auth import get_user_model
class AuthTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = get_user_model().objects.create_user("reader", password="a-test-only-password")
@override_settings(MIDDLEWARE=DEFAULT + ["django.contrib.auth.middleware.LoginRequiredMiddleware"], LOGIN_URL="/accounts/login/")
def test_login_required_middleware(self):
r = self.client.get("/members/")
self.assertEqual((r.status_code, r["Location"]), (302, "/accounts/login/?next=/members/"))
self.client.force_login(self.user)
self.assertEqual(self.client.get("/members/").status_code, 200)
@override_settings(
MIDDLEWARE=DEFAULT + ["django.contrib.auth.middleware.LoginRequiredMiddleware"],
ROOT_URLCONF="catalog.urls_public",
LOGIN_URL="/accounts/login/",
)
def test_login_not_required(self):
self.assertEqual(self.client.get("/members/").status_code, 302)
self.assertEqual(self.client.get("/books/").status_code, 200)
Source: LoginRequiredMiddleware and Django 5.1 release notes
The most common mistake is forgetting that the login view itself must be public: with the middleware on, an unmarked
login view redirects to itself forever, so mark it with login_not_required (Django’s own LoginView is already
exempt). Also remember the static and media routes, API endpoints that use token authentication, and webhook views.
This middleware complements, and does not replace, per-view permission checks: it only answers "is there a logged-in
user", not "is this user allowed".
RemoteUserMiddleware and PersistentRemoteUserMiddleware
RemoteUserMiddleware authenticates users from a variable or header that the web server sets after its own
authentication (single sign-on in front of Django). It must come after AuthenticationMiddleware and works with
RemoteUserBackend; PersistentRemoteUserMiddleware is the variant for setups where the variable is only available on
the login page. The security rule is absolute: the web server must strip the header from client requests and set it
itself, because the middleware trusts whatever it finds. In 6.1, under ASGI, the middleware no longer prefixes HTTP_
when it looks up a custom header in request.META, restoring the behaviour before 5.2 (the default REMOTE_USER is
not affected).
class RemoteUserTests(TestCase):
@override_settings(
MIDDLEWARE=DEFAULT[:4]
+ [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.auth.middleware.RemoteUserMiddleware",
],
AUTHENTICATION_BACKENDS=["django.contrib.auth.backends.RemoteUserBackend"],
)
def test_remote_user(self):
r = self.client.get("/whoami/", REMOTE_USER="alice")
self.assertEqual(r.json()["user"], "alice") # user created on first sight
CommonMiddleware and BrokenLinkEmailsMiddleware
CommonMiddleware adds a few conveniences:
-
DISALLOWED_USER_AGENTS: a list of compiled regular expressions; matching user agents get a 403. -
APPEND_SLASH(defaultTrue): when the URL does not end with a slash and does not resolve, but would with a slash, the client is redirected (301) to the slashed URL. The philosophy is that each URL should live in one place;/booksand/books/are different URLs to a search engine. Individual views can opt out with theno_append_slashdecorator (3.2; decorator support for async views since 5.0). -
PREPEND_WWW(defaultFalse): redirect hosts without a leadingwww.to thewww.form. -
Content-Length: set for non-streaming responses. -
response_redirect_class: defaults toHttpResponsePermanentRedirect; subclass and override it to change the redirects.
from django.http import HttpResponse
from django.views.decorators.common import no_append_slash
@no_append_slash
def raw_endpoint(request): # routed as path("raw", views.raw_endpoint)
return HttpResponse("no slash redirect for me")
import re
class CommonTests(TestCase):
@override_settings(MIDDLEWARE=DEFAULT)
def test_append_slash_and_decorator(self):
r = self.client.get("/books") # route is books/
self.assertEqual((r.status_code, r["Location"]), (301, "/books/"))
self.assertEqual(self.client.get("/raw").status_code, 200) # the route has no slash
@override_settings(MIDDLEWARE=DEFAULT, PREPEND_WWW=True, ALLOWED_HOSTS=["example.test", "www.example.test"])
def test_prepend_www(self):
r = self.client.get("/books/", headers={"Host": "example.test"})
self.assertEqual((r.status_code, r["Location"]), (301, "http://www.example.test/books/"))
@override_settings(MIDDLEWARE=DEFAULT, DISALLOWED_USER_AGENTS=[re.compile(r"badbot", re.I)])
def test_disallowed_agents(self):
self.assertEqual(self.client.get("/books/", headers={"User-Agent": "BadBot/1.0"}).status_code, 403)
self.assertEqual(self.client.get("/books/").status_code, 200)
Source: Common middleware
Redirecting a POST to the slashed URL loses the body, which is why APPEND_SLASH raises a developer error in DEBUG
mode for POST requests to a URL that needs a slash: always define the form action and API client URLs with the
trailing slash.
BrokenLinkEmailsMiddleware sends 404 notifications to the people in MANAGERS when the missing URL was reached from
a link on another page (the Referer is present and not from your own site, and
IGNORABLE_404_URLS can silence noisy paths). In 6.1 its internal implementation moved to the new mailers
framework (MAILERS), so a subclass that customized how mail is sent should be reviewed.
from django.core import mail
class BrokenLinkTests(TestCase):
@override_settings(MIDDLEWARE=DEFAULT + ["django.middleware.common.BrokenLinkEmailsMiddleware"],
MANAGERS=[("Ops", "ops@example.test")], DEBUG=False)
def test_broken_links(self):
self.client.get("/gone/", headers={"Referer": "https://other.example/page"})
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, "[Django] Broken link on testserver")
self.assertEqual(mail.outbox[0].to, ["ops@example.test"])
Source: Error reporting: errors and BrokenLinkEmailsMiddleware
CsrfViewMiddleware
CsrfViewMiddleware adds protection against cross-site request forgery: it checks unsafe methods (POST, PUT,
PATCH, DELETE) for a token that must match the CSRF cookie (or the session when CSRF_USE_SESSIONS is on), with the
{% csrf_token %} tag in forms or the X-CSRFToken header in AJAX. A failed check returns a 403. Individual views
can be wrapped with csrf_protect (protect even without the middleware), csrf_exempt, ensure_csrf_cookie or
requires_csrf_token; since 5.0 these decorators also accept async views. Since 4.0 the check also validates the
Origin header against CSRF_TRUSTED_ORIGINS, whose entries must include the scheme. The middleware is
essentially required for any site with cookie-based authentication.
from django.test import Client
class CsrfTests(TestCase):
@override_settings(MIDDLEWARE=DEFAULT)
def test_csrf(self):
c = Client(enforce_csrf_checks=True) # the test client skips CSRF by default
self.assertEqual(c.post("/whoami/").status_code, 403)
The test client disables the check unless you pass enforce_csrf_checks=True, which is why a view that works in tests can
fail in a browser until the token is present.
MessageMiddleware and XFrameOptionsMiddleware
MessageMiddleware enables cookie- and session-based flash messages (django.contrib.messages); it must come after
SessionMiddleware because the default storage can use the session. XFrameOptionsMiddleware sets the
X-Frame-Options header, by default DENY (the setting is X_FRAME_OPTIONS; a view can override with the
xframe_options_* decorators), so other sites cannot embed your pages in a frame; the output of the stack above
shows X-Frame-Options: DENY.
Source: X-Frame-Options middleware and Clickjacking protection
ContentSecurityPolicyMiddleware
New in 6.0, ContentSecurityPolicyMiddleware sets Content-Security-Policy from SECURE_CSP and
Content-Security-Policy-Report-Only from SECURE_CSP_REPORT_ONLY, so a new policy can be observed before it is
enforced. Policies are dictionaries; django.utils.csp.CSP provides constants such as CSP.SELF and CSP.NONCE.
The nonce is generated lazily, only when something reads request.csp_nonce (typically the csp() context processor or
the 6.1 {% csp_nonce_attr %} tag), so a middleware that reads it must sit after this one; a 6.1 system check
(security.W027) warns when CSP.NONCE is used without the context processor. The full policy language belongs to the
security page of this section; the middleware’s own contribution is the headers.
from django.utils.csp import CSP
class CspTests(TestCase):
@override_settings(
MIDDLEWARE=DEFAULT + ["django.middleware.csp.ContentSecurityPolicyMiddleware"],
SECURE_CSP={"default-src": [CSP.SELF], "script-src": [CSP.SELF, CSP.NONCE]},
SECURE_CSP_REPORT_ONLY={"default-src": [CSP.SELF], "report-uri": "/csp-reports/"},
)
def test_csp(self):
r = self.client.get("/books/")
self.assertEqual(r["Content-Security-Policy"], "default-src 'self'; script-src 'self'")
self.assertEqual(
r["Content-Security-Policy-Report-Only"], "default-src 'self'; report-uri /csp-reports/"
)
Because nothing in that request read the nonce, the 'nonce-…' source is not in the header: the nonce appears only
once a template or middleware asks for it.
GZipMiddleware and ConditionalGetMiddleware
GZipMiddleware compresses responses for browsers that send Accept-Encoding: gzip. It does not compress bodies under
200 bytes, responses that already have a Content-Encoding, or requests that did not ask for gzip, and it makes an
existing ETag weak. Compression and secrets together can leak through the BREACH attack, so Django adds up to
max_random_bytes (default 100) of random bytes to each compressed response (the Heal The Breach technique, added in 4.2). Subclass the middleware and override max_random_bytes to
change the amount. For one view use the gzip_page decorator.
ConditionalGetMiddleware adds an ETag if the response lacks one, and when the request carries If-None-Match or
If-Modified-Since that match the response’s ETag or Last-Modified, it replaces the response with a 304
HttpResponseNotModified. It saves bandwidth but not view time, because the view still runs to produce the response to
hash; for per-view conditions that avoid running the view, use the condition, etag and last_modified decorators
(the conditional view processing covered on the function-based views page). Its ETag is computed on the body at that
point, so it must be listed after GZipMiddleware: that makes it the inner layer, which hashes the plain body before the outer
GZip layer compresses it.
class GzipConditionalTests(TestCase):
@override_settings(MIDDLEWARE=[
"django.middleware.gzip.GZipMiddleware",
"django.middleware.http.ConditionalGetMiddleware",
"django.middleware.common.CommonMiddleware",
])
def test_gzip_then_conditional(self):
r = self.client.get("/books/", headers={"Accept-Encoding": "gzip"})
self.assertEqual(r["Content-Encoding"], "gzip") # 800 bytes of text became under 70 bytes
etag = self.client.get("/books/")["ETag"]
r = self.client.get("/books/", headers={"If-None-Match": etag})
self.assertEqual(r.status_code, 304)
small = self.client.get("/healthz/", headers={"Accept-Encoding": "gzip"})
self.assertNotIn("Content-Encoding", small) # under 200 bytes: not compressed
Source: GZip middleware and Conditional GET middleware
LocaleMiddleware
LocaleMiddleware selects the language per request from the URL prefix (with i18n_patterns), the language
cookie and the Accept-Language header, activates it, and sets Content-Language and Vary: Accept-Language on the
response. It customizes content for each user and has response_redirect_class, which defaults to
HttpResponseRedirect. Place it after SessionMiddleware and UpdateCacheMiddleware, and before CommonMiddleware. The i18n page of this section
covers translation itself.
class LocaleTests(TestCase):
@override_settings(
MIDDLEWARE=DEFAULT[:2] + ["django.middleware.locale.LocaleMiddleware"] + DEFAULT[2:],
LANGUAGES=[("en", "English"), ("es", "Spanish")],
)
def test_locale(self):
r = self.client.get("/books/", headers={"Accept-Language": "es"})
self.assertEqual(r["Content-Language"], "es")
self.assertIn("Accept-Language", r["Vary"])
UpdateCacheMiddleware and FetchFromCacheMiddleware
Together they enable the site-wide cache: each page is cached for CACHE_MIDDLEWARE_SECONDS. They must be a pair, with
UpdateCacheMiddleware first in the list (it acts on the way out, so it is the last to see the response, after every
middleware that modifies Vary) and FetchFromCacheMiddleware last of the group (it acts on the way in, after everything
that sets Vary). Only GET and HEAD responses with a 200 or 304 status are considered, and in 6.1.1 the code skips streaming responses, responses marked private, no-cache or no-store in Cache-Control, and responses that set cookies while varying on Cookie. The
cache key includes the Vary headers, so per-user pages are not leaked as long as Vary: Cookie is present, but it is
easy to cache something that should not be public: the site-wide cache is for sites that are entirely anonymous. The
choice of cache backend (LocMem, database, file, Memcached, Redis) is the subject of the caching page; the example uses
LocMemCache, and a Redis cache only changes the CACHES entry. In 6.1 the cache keys for pages that vary on extra
information changed, so the first request after an upgrade is a miss.
class SiteCacheTests(TestCase):
@override_settings(
MIDDLEWARE=[
"django.middleware.cache.UpdateCacheMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.cache.FetchFromCacheMiddleware",
],
CACHE_MIDDLEWARE_SECONDS=60,
CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}},
)
def test_site_cache(self):
a = self.client.get("/whoami/")
b = self.client.get("/whoami/")
self.assertEqual(a.content, b.content) # the second answer came from the cache
self.assertEqual(a["Cache-Control"], "max-age=60")
Source: The per-site cache
Sites, redirects and flatpages
Three django.contrib apps ship a middleware. CurrentSiteMiddleware adds request.site, the Site for the current
request, used by apps that serve several domains from one database. RedirectFallbackMiddleware and
FlatpageFallbackMiddleware are fallback middleware: they act only when the response is a 404, look the path up in
the database, and return a redirect or a flat page. That is why they belong near the bottom of the list. All three need
their apps in INSTALLED_APPS and, for sites, SITE_ID.
class SitesRedirectsTests(TestCase):
@override_settings(MIDDLEWARE=DEFAULT + [
"django.contrib.sites.middleware.CurrentSiteMiddleware",
"django.contrib.redirects.middleware.RedirectFallbackMiddleware",
])
def test_sites_and_redirects(self):
from django.contrib.redirects.models import Redirect
from django.contrib.sites.models import Site
Redirect.objects.create(site=Site.objects.get(pk=1), old_path="/old-books/", new_path="/books/")
r = self.client.get("/old-books/") # unresolved URL: 404, then the fallback redirects
self.assertEqual((r.status_code, r["Location"]), (301, "/books/"))
Source: The sites framework, The redirects app and The flatpages app
The scratch project had django.contrib.sites and django.contrib.redirects in INSTALLED_APPS and SITE_ID = 1.
Third-party middleware you will meet
Outside django.*, the usual suspects are WhiteNoise (static files, directly after SecurityMiddleware), django-debug-toolbar
(as early as possible, but after anything that encodes the response such as GZipMiddleware), django-cors-headers (before
CommonMiddleware; see CORS), and the HTMX and allauth middleware. Each documents its own position in
the list; the rule from Ordering applies: ask what it reads and what it writes.
A small custom example: subdomains
The Bookshelf can serve a catalogue per genre from subdomains (scifi.example.com, poetry.example.com). One
custom middleware sets request.subdomain, and the views read it. The security point is where the host comes from:
request.get_host() validates the Host header against ALLOWED_HOSTS and raises DisallowedHost (a 400) otherwise,
while request.META["HTTP_HOST"] is raw client input.
class SubdomainMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
host = request.get_host().split(":")[0] # validated against ALLOWED_HOSTS
parts = host.split(".")
request.subdomain = parts[0] if len(parts) > 2 else None
return self.get_response(request)
class SubdomainTests(TestCase):
@override_settings(MIDDLEWARE=BASE + ["catalog.middleware.SubdomainMiddleware"], ALLOWED_HOSTS=[".example.test"])
def test_subdomain(self):
r = self.client.get("/whoami/", headers={"Host": "scifi.example.test"})
self.assertEqual(r.json()["subdomain"], "scifi")
r = self.client.get("/whoami/", headers={"Host": "example.test"})
self.assertIsNone(r.json()["subdomain"])
self.assertEqual(self.client.get("/whoami/", headers={"Host": "evil.test"}).status_code, 400)
Source: HttpRequest.get_host() and ALLOWED_HOSTS
Testing middleware
Two approaches work, and both were used above.
-
Through the stack.
override_settings(MIDDLEWARE=[…])with the test client (orAsyncClient) exercises the chain as a browser would, including ordering and the hooks. List only the middleware the test needs (plus the ones yours depends on); construct a new client after changing the setting, because the chain is built once per handler. The sync client runs a sync view through an async-only middleware and the async client runs an async view, which is how the tests above cover both modes. -
In isolation. A middleware factory is just a function, so it can be tested with
RequestFactoryand a stubget_response, without a URL, a database or settings.
from django.http import HttpResponse
from django.test import RequestFactory, SimpleTestCase
from catalog.middleware import RequestIdMiddleware, request_timing
class FactoryTests(SimpleTestCase):
def test_request_id_without_the_stack(self):
seen = {}
def get_response(request):
seen["id"] = request.request_id
return HttpResponse("ok")
response = RequestIdMiddleware(get_response)(RequestFactory().get("/anything/"))
self.assertEqual(response["X-Request-ID"], seen["id"])
def test_timing_header(self):
response = request_timing(lambda request: HttpResponse("ok"))(RequestFactory().get("/"))
self.assertTrue(response["Server-Timing"].startswith("app;dur="))
Source: The request factory and Overriding settings
Pitfalls checklist
-
Per-request state on
self. One instance serves every request, concurrently; put request state on therequestobject (request.request_id) or in a local variable. -
Blocking work in an async chain. A sync-only middleware under ASGI costs a thread hop for the rest of the chain; make yours hybrid, and never call the synchronous ORM from async code.
-
Reading
request.bodyorrequest.POSTearly. It consumes or parses the body before the view can choose upload handlers (see the file-upload handlers); read it only if you must, and only in the layer that needs it. -
Consuming a streaming response.
response.contentraises for streaming responses; wrapstreaming_content, do not iterate it. -
Wrong order.
AuthenticationMiddlewarebeforeSessionMiddlewarefails at the first request;GZipMiddlewareafterConditionalGetMiddlewarehashes compressed bytes; a header added outsideCommonMiddlewareafter the body changed leaves a wrongContent-Length. -
Trusting client input. Validate incoming ids and hosts (
get_host()rather than the rawHostheader), and never let a header value containing newlines reach logs or response headers. -
Catching exceptions around
get_response. They never arrive there; look at the status code, or useprocess_exceptionfor exceptions raised by the view. -
MIDDLEWARE_CLASSESandprocess_requestwithout the mixin. Both are ignored in current Django. -
Heavy work in
init. It runs once at start-up and delays the first request; but equally do not read mutable settings there if tests need to change them (override_settingsdoes not re-runinitfor an existing client). -
A hook on a function middleware.
process_viewand friends exist only on classes; a function has none. -
Forgetting the login view. With
LoginRequiredMiddleware, mark login, static and health-check views public. -
Using middleware where a decorator or a mixin fits. Middleware is global; if only two views need the behaviour use a decorator or a class-based view mixin so the rest of the site does not pay for it.
Where the books are outdated
The four reference books, written for Django 1.11–2.1 (and tutorials derived from them), describe middleware as it was before the current model, and a few details have moved on:
-
MIDDLEWARE_CLASSESandprocess_request/process_responseclasses. The old style was replaced byMIDDLEWAREin 1.10 andMIDDLEWARE_CLASSESwas removed in 2.0. A class with onlyprocess_requestand noMiddlewareMixinis silently inert today. Use a function or acallclass, or the mixin for a port. -
Skipped middleware. Old behaviour called every
process_responseeven when an earlier layer short-circuited; the onion model calls only the layers the request passed through. -
No async. Middleware could not be asynchronous before 3.1;
sync_capable,async_capableand the three decorators (3.1), andmarkcoroutinefunction(anasgirefhelper first, in the standard library since Python 3.12) are missing from older text, as is the concept of adaptation cost. -
Security defaults. Pre-4.0 text mentions
SECURE_BROWSER_XSS_FILTERandX-XSS-Protection, both gone since 4.0;CsrfViewMiddlewarenow validatesOriginandCSRF_TRUSTED_ORIGINSneeds schemes (4.0); the defaultCross-Origin-Opener-Policyheader arrived in 4.0. -
MiddlewareMixin.init.get_responseis required since 4.0 (aNonedefault was deprecated in 3.1). -
Per-site middleware examples. Subdomain and "current tenant" middleware in older books often read
request.META["HTTP_HOST"]directly, which skipsALLOWED_HOSTSvalidation; useget_host(). -
SessionAuthenticationMiddleware. Removed in 2.0; session invalidation on password change is built in. -
Static files. Old advice to serve statics through a Django view or
django.views.static.servein production has been replaced by a front-end server or a WSGI static middleware such as WhiteNoise. -
Authentication enforcement. Books recommend
login_requiredon every view or a hand-written "login everywhere" middleware;LoginRequiredMiddleware(5.1) is the built-in answer.
What changed recently
| Version | Change |
|---|---|
1.10 / 2.0 |
New-style |
3.1 |
Asynchronous middleware: |
3.2 |
|
4.0 |
|
4.2 |
|
5.0 |
|
5.1 |
|
6.0 |
|
6.1 |
|