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: get_response in, a callable that takes a request and returns a response out. A function or a class with call.

Writing middleware

Do setup once

In the factory body or init; it runs once, when the server loads the handler and builds the middleware chain, not per request.

One-time setup, init and MiddlewareNotUsed

Skip a middleware at start-up

Raise MiddlewareNotUsed from init.

One-time setup, init and MiddlewareNotUsed

Answer without calling the view

Return a response without calling get_response; layers inside you never see the request.

Short-circuiting

Act on the resolved view or on its exceptions

process_view, process_exception, process_template_response on a class middleware.

The three hooks: process_view, process_exception and process_template_response

Work with streamed bodies

Check response.streaming and wrap streaming_content; never consume it.

Streaming responses

Support ASGI without penalty

Set sync_capable and async_capable (or use @sync_and_async_middleware) and branch on iscoroutinefunction(get_response).

Sync and async middleware

Order the list

Top-down on the way in, bottom-up on the way out; follow the documented ordering hints.

Ordering

Use a built-in

Add the dotted path to MIDDLEWARE; each has settings and ordering constraints.

Built-in middleware reference

Test it

Client with override_settings(MIDDLEWARE=[…​]), or call the factory directly with a stub get_response.

Testing middleware

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.

sequenceDiagram participant C as Client participant S as SecurityMiddleware participant Se as SessionMiddleware participant Co as CommonMiddleware participant U as URL resolver and view C->>S: request S->>Se: get_response(request) Se->>Co: get_response(request) Co->>U: get_response(request) U-->>Co: response Co-->>Se: response Se-->>S: response S-->>C: response

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 for PermissionDenied, a 500 for anything else) between each pair of layers, so a middleware that calls get_response receives a response object, never the exception.

  • get_response is 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.

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.

The default MIDDLEWARE list (config/settings.py)
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",
]

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.

A function middleware that adds a Server-Timing header (catalog/middleware.py)
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

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:

Checking the header with curl
curl -si http://127.0.0.1:8000/books/ | grep -i server-timing
Server-Timing: app;dur=0.1

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.

A class middleware that attaches a request id (catalog/middleware.py)
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

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:

Testing the request id middleware (catalog/tests.py)
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")

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.

Opting out at start-up and short-circuiting (catalog/middleware.py)
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"},
        )

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:

Testing MiddlewareNotUsed and short-circuiting (catalog/tests.py)
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)

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

process_view(request, view_func, view_args, view_kwargs)

Just before Django calls the view, after URL resolution and after every middleware’s call code ran its "before" part.

None to continue, or an HttpResponse to skip the view (response middleware still runs).

Top-down, in MIDDLEWARE order.

process_exception(request, exception)

When the view raises (or when rendering a TemplateResponse raises).

None for default handling, or an HttpResponse.

Bottom-up; the first one to return a response stops the rest.

process_template_response(request, response)

After the view returns, only if the response has a render() method (a TemplateResponse or equivalent).

A response object with a render() method.

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

A middleware with all three hooks (catalog/middleware.py)
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

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:

The views used by the hook tests (catalog/views.py)
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")
The template (catalog/templates/catalog/book_detail.html)
<h1>{{ title }}</h1>
{% if banner %}<p class="banner">{{ banner }}</p>{% endif %}
Testing the three hooks (catalog/tests.py)
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.

A middleware only ever sees responses, never exceptions (catalog/tests.py)
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

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.

A streaming-aware middleware for sync and async iterators (catalog/middleware.py)
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:

Testing the sync and async branches (catalog/tests.py)
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")

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

sync_capable

True

The middleware can handle synchronous requests.

async_capable

False

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.

A hybrid function middleware (catalog/middleware.py)
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

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

An async-only class middleware (catalog/middleware.py)
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
Testing hybrid and async-only middleware under both clients (catalog/tests.py)
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

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 async view 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 DEBUG logging on django.request the adaptation is logged, for example Asynchronous handler adapted for middleware catalog.middleware.request_timing. when the scratch project’s chain ran under uvicorn. The fix is to make your own middleware hybrid, not to avoid ASGI.

  • The hooks adapt individually. process_view, process_template_response and process_exception should 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()) or sync_to_async; the async-unsafe check raises SynchronousOnlyOperation otherwise.

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

Porting an old-style middleware with MiddlewareMixin (catalog/middleware.py)
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:

Tracing the onion (catalog/middleware.py)
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")
Testing the order and a short-circuit (catalog/tests.py)
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

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

SecurityMiddleware

Near the top if you turn on the SSL redirect, so the redirect avoids running through other middleware.

UpdateCacheMiddleware

Before those that modify the Vary header (SessionMiddleware, GZipMiddleware, LocaleMiddleware).

GZipMiddleware

Before any middleware that may change or use the response body; after UpdateCacheMiddleware because it modifies Vary.

SessionMiddleware

Before any middleware that may raise an exception to trigger an error view (such as PermissionDenied) if CSRF_USE_SESSIONS is on; after UpdateCacheMiddleware.

ConditionalGetMiddleware

Before any middleware that may change the response (it sets the ETag); after GZipMiddleware so it does not compute an ETag on compressed content.

LocaleMiddleware

One of the topmost, after SessionMiddleware (it uses session data) and UpdateCacheMiddleware.

CommonMiddleware

Before any middleware that may change the response (it sets Content-Length; a middleware before it that changes the response must reset Content-Length); close to the top because it redirects for APPEND_SLASH and PREPEND_WWW; after SessionMiddleware if CSRF_USE_SESSIONS is on.

CsrfViewMiddleware

Before any view middleware that assumes CSRF attacks were dealt with; before RemoteUserMiddleware or any authentication middleware that may log a user in (which rotates the token); after SessionMiddleware if CSRF_USE_SESSIONS is on.

AuthenticationMiddleware

After SessionMiddleware (it uses session storage).

LoginRequiredMiddleware

After AuthenticationMiddleware (it uses the user object).

MessageMiddleware

After SessionMiddleware (it can use session-based storage).

FetchFromCacheMiddleware

After any middleware that modifies the Vary header, which is used to pick the cache key.

ContentSecurityPolicyMiddleware

Can be near the bottom. The csp_nonce is lazy, so any middleware that reads it must be positioned after this one, so the nonce exists before this middleware builds the header.

FlatpageFallbackMiddleware, RedirectFallbackMiddleware

Near the bottom: last-resort middleware that only act when the response is a 404.

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.

The Bookshelf MIDDLEWARE list (config/settings.py)
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:

The response headers under runserver
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.

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

SecurityMiddleware

django.middleware.security

HTTPS redirect, HSTS, nosniff, referrer and opener policies.

SessionMiddleware

django.contrib.sessions.middleware

Loads the session into request.session, saves it and sets the cookie.

CommonMiddleware

django.middleware.common

APPEND_SLASH, PREPEND_WWW, DISALLOWED_USER_AGENTS, Content-Length.

BrokenLinkEmailsMiddleware

django.middleware.common

Emails MANAGERS about 404s that come from a link on another page.

CsrfViewMiddleware

django.middleware.csrf

Checks the CSRF token on unsafe methods.

AuthenticationMiddleware

django.contrib.auth.middleware

Adds request.user (lazy) and, since 5.0, request.auser().

LoginRequiredMiddleware

django.contrib.auth.middleware

Redirects every anonymous request to the login page unless the view opts out (5.1).

RemoteUserMiddleware, PersistentRemoteUserMiddleware

django.contrib.auth.middleware

Authenticates from a web-server-provided header or variable.

MessageMiddleware

django.contrib.messages.middleware

Cookie- and session-based flash messages.

XFrameOptionsMiddleware

django.middleware.clickjacking

X-Frame-Options against clickjacking.

ContentSecurityPolicyMiddleware

django.middleware.csp

Content-Security-Policy headers from SECURE_CSP (6.0).

GZipMiddleware

django.middleware.gzip

Compresses responses for clients that accept gzip.

ConditionalGetMiddleware

django.middleware.http

ETag, If-None-Match and If-Modified-Since handling, 304 responses.

LocaleMiddleware

django.middleware.locale

Chooses the language from the request.

UpdateCacheMiddleware, FetchFromCacheMiddleware

django.middleware.cache

The site-wide cache.

CurrentSiteMiddleware

django.contrib.sites.middleware

Adds request.site.

FlatpageFallbackMiddleware

django.contrib.flatpages.middleware

Serves a flat page when the URL resolves to a 404.

RedirectFallbackMiddleware

django.contrib.redirects.middleware

Redirects stored in the database when the URL resolves to a 404.

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

SECURE_SSL_REDIRECT

Permanently redirects (301) HTTP requests to HTTPS; SECURE_SSL_HOST sets the host to redirect to and SECURE_REDIRECT_EXEMPT lists regular expressions of paths that stay on HTTP. Behind a proxy that terminates TLS also set SECURE_PROXY_SSL_HEADER, otherwise Django cannot tell that the request was secure.

SECURE_HSTS_SECONDS

Non-zero adds Strict-Transport-Security to HTTPS responses; start small (3600) and raise to a year (31536000) once nothing breaks. SECURE_HSTS_INCLUDE_SUBDOMAINS and SECURE_HSTS_PRELOAD add the matching directives; the policy covers the whole domain, so use it only if all of it is HTTPS.

SECURE_CONTENT_TYPE_NOSNIFF

Adds X-Content-Type-Options: nosniff, stopping browsers from guessing content types (useful when Django serves user uploads).

SECURE_REFERRER_POLICY

Sets Referrer-Policy; a string, an iterable, or comma-separated values with fallbacks (the last value the browser understands wins). Disabling Referer entirely breaks CSRF protection over HTTPS; same-origin keeps it.

SECURE_CROSS_ORIGIN_OPENER_POLICY

Sets Cross-Origin-Opener-Policy (same-origin, same-origin-allow-popups, unsafe-none, or None to omit). Added by default as same-origin in 4.0.

Security settings and the headers they produce (catalog/test_builtin.py)
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

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

Enabling LoginRequiredMiddleware and opting a view out (config/settings.py, catalog/urls_public.py)
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
]
Testing the redirect, the login and the opt-out (catalog/test_builtin.py)
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)

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

Remote user authentication in a test (catalog/test_builtin.py)
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 (default True): 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; /books and /books/ are different URLs to a search engine. Individual views can opt out with the no_append_slash decorator (3.2; decorator support for async views since 5.0).

  • PREPEND_WWW (default False): redirect hosts without a leading www. to the www. form.

  • Content-Length: set for non-streaming responses.

  • response_redirect_class: defaults to HttpResponsePermanentRedirect; subclass and override it to change the redirects.

APPEND_SLASH, no_append_slash, PREPEND_WWW and DISALLOWED_USER_AGENTS (catalog/views.py, catalog/test_builtin.py)
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)

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.

Emailing managers about broken external links (catalog/test_builtin.py)
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"])

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.

CSRF enforced by the middleware (catalog/test_builtin.py)
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.

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.

Enforcing one policy and reporting another (catalog/test_builtin.py)
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.

Compression, weak ETags and 304 responses (catalog/test_builtin.py)
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

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.

Language chosen from Accept-Language (catalog/test_builtin.py)
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.

The site-wide cache around CommonMiddleware (catalog/test_builtin.py)
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")

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.

Database redirects with RedirectFallbackMiddleware (catalog/test_builtin.py)
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/"))

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.

A subdomain middleware (catalog/middleware.py)
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)
Testing the subdomain middleware and host validation (catalog/tests.py)
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)

Testing middleware

Two approaches work, and both were used above.

  • Through the stack. override_settings(MIDDLEWARE=[…​]) with the test client (or AsyncClient) 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 RequestFactory and a stub get_response, without a URL, a database or settings.

Unit-testing a factory with a stub get_response (catalog/tests.py)
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="))

Pitfalls checklist

  • Per-request state on self. One instance serves every request, concurrently; put request state on the request object (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.body or request.POST early. 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.content raises for streaming responses; wrap streaming_content, do not iterate it.

  • Wrong order. AuthenticationMiddleware before SessionMiddleware fails at the first request; GZipMiddleware after ConditionalGetMiddleware hashes compressed bytes; a header added outside CommonMiddleware after the body changed leaves a wrong Content-Length.

  • Trusting client input. Validate incoming ids and hosts (get_host() rather than the raw Host header), 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 use process_exception for exceptions raised by the view.

  • MIDDLEWARE_CLASSES and process_request without 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_settings does not re-run init for an existing client).

  • A hook on a function middleware. process_view and 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_CLASSES and process_request/process_response classes. The old style was replaced by MIDDLEWARE in 1.10 and MIDDLEWARE_CLASSES was removed in 2.0. A class with only process_request and no MiddlewareMixin is silently inert today. Use a function or a call class, or the mixin for a port.

  • Skipped middleware. Old behaviour called every process_response even 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_capable and the three decorators (3.1), and markcoroutinefunction (an asgiref helper 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_FILTER and X-XSS-Protection, both gone since 4.0; CsrfViewMiddleware now validates Origin and CSRF_TRUSTED_ORIGINS needs schemes (4.0); the default Cross-Origin-Opener-Policy header arrived in 4.0.

  • MiddlewareMixin.init. get_response is required since 4.0 (a None default 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 skips ALLOWED_HOSTS validation; use get_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.serve in production has been replaced by a front-end server or a WSGI static middleware such as WhiteNoise.

  • Authentication enforcement. Books recommend login_required on 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 MIDDLEWARE introduced (1.10); MIDDLEWARE_CLASSES removed (2.0).

3.1

Asynchronous middleware: sync_capable, async_capable and the decorators in django.utils.decorators; ConditionalGetMiddleware no longer adds an ETag to empty responses.

3.2

no_append_slash() decorator.

4.0

SecurityMiddleware adds Cross-Origin-Opener-Policy (default same-origin) and no longer sets X-XSS-Protection (SECURE_BROWSER_XSS_FILTER removed); CsrfViewMiddleware checks Origin and CSRF_TRUSTED_ORIGINS needs a scheme; MiddlewareMixin.get_response required.

4.2

GZipMiddleware gets the Heal The Breach mitigation (random bytes, max_random_bytes); StreamingHttpResponse accepts async iterators under ASGI.

5.0

AuthenticationMiddleware adds request.auser(); view decorators such as no_append_slash, csrf_exempt, gzip_page and condition support async views.

5.1

LoginRequiredMiddleware and login_not_required().

6.0

ContentSecurityPolicyMiddleware with SECURE_CSP and SECURE_CSP_REPORT_ONLY.

6.1

{% csp_nonce_attr %} and the security.W027 check for CSP nonces; BrokenLinkEmailsMiddleware moved onto the mailers framework (MAILERS); under ASGI RemoteUserMiddleware stops prefixing HTTP_ to custom header names (restoring the pre-5.2 behaviour); cache keys that vary on additional headers changed, so UpdateCacheMiddleware entries miss once after upgrading.