Django middleware
last modified November 13, 2026 Python 3.15
Middleware is code that sits between the web server and the view. Every request passes through a chain of
middleware on its way to the view, and every response passes back through the same chain in the opposite order.
This makes middleware the right place for work that concerns all requests: adding headers, measuring time,
authenticating users, handling sessions, compressing responses, or turning exceptions into responses. In this article we write function-based and class-based
middleware, watch the order in which the pieces are called, use the special hooks of class-based middleware, write a middleware that works in sync and async
mode, look at the built-in SecurityMiddleware, and test it all. The examples use Django 6.1 and Python 3.15.
How middleware works
The setting MIDDLEWARE is a list of dotted paths. At startup Django builds the chain from the last item to the first: each middleware
is created with the next one as its argument get_response, so calling get_response(request) passes the request on to the
next layer, and finally to the view. The first item in the list is the outermost layer: it sees the request first and the response last. A middleware
may do something before calling get_response, something after it returns, or not call it at all and return a response of its own, which
is called short-circuiting. Django accepts two forms: a function that returns an inner function, and a class with __init__(get_response)
and __call__(request). The class form may add optional hook methods that Django calls at special moments. The middleware is created once, when the first request arrives,
and the same object serves all requests, so it must not keep per-request data in its attributes.
The example project
The project is created with uv; the steps are explained in
Django introduction. The commands below create a project
directory, install Django, generate the project with the configuration package
config, start the app pages, and create the folder demos for demo scripts.
$ mkdir middleware $ cd middleware $ uv init --bare $ uv add django $ uv run django-admin startproject config . $ uv run python manage.py startapp pages $ mkdir demos
The trailing dot in startproject config . creates manage.py and the
config package in the current directory instead of one level deeper. A new app is
ignored by Django until it is listed in INSTALLED_APPS, so we add
the app to the list in config/settings.py; the other
entries are generated by startproject.
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'pages',
]
The listings in this article are of two kinds. A listing with a solid blue border and the label
Project file is a file of the application: models, views, templates, tests, and
settings. A listing with a dashed amber border and the label Demo script is a small
helper in the demos folder; it sets up Django, runs a few lines of code, and prints
the results, so that we can watch the behaviour. Demo scripts are not part of the application;
we run one from the project directory with uv run python -m demos.name.
The app pages has no models. Its views exist only to be wrapped by middleware: index uses the attribute that our first
middleware adds to the request, plain and health are trivial, article returns a TemplateResponse,
and the others fail in three different ways.
from django.http import Http404, HttpResponse
from django.template.response import TemplateResponse
from .hooks import ApiError
def index(request):
return HttpResponse(f'Hello, request {request.request_id}')
def plain(request):
return HttpResponse('plain')
def health(request):
return HttpResponse('ok')
def article(request, slug):
return TemplateResponse(request, 'pages/article.html', {'slug': slug})
def boom(request):
raise ApiError('the quota is exhausted', status=429)
def crash(request):
raise RuntimeError('unexpected')
def missing(request):
raise Http404('no such page')
The view boom raises our own exception ApiError, crash raises an unexpected
RuntimeError, and missing raises Http404. The exception class is defined in pages/hooks.py, which we write in a later section.
The URL patterns connect the paths with the views.
from django.urls import path
from . import views
urlpatterns = [
path('', views.index),
path('plain/', views.plain),
path('health/', views.health),
path('articles/<slug:slug>/', views.article),
path('boom/', views.boom),
path('crash/', views.crash),
path('missing/', views.missing),
]
The project URL configuration includes the patterns of the app at the root.
from django.urls import include, path
urlpatterns = [
path('', include('pages.urls')),
]
The template of the article view prints a variable, view_name, that no view sets; a middleware will add it.
<h1>{{ slug }}</h1>
<p>Rendered by {{ view_name }}</p>
The template is a short fragment, not a full page, which is enough for the demonstration.
Function and class middleware
The first middleware is a function. request_id_middleware takes get_response and returns the function
middleware, which is called for every request. It reads the header X-Request-ID or generates a short identifier, stores
it on the request so that the view can use it, calls the next layer, and copies the identifier to the response. A proxy in front of the application can
send the same header, so the identifier then follows a request through several systems. The class TimingMiddleware does the same
in the class form: __init__ keeps get_response, and __call__ measures the time around the call. The third
middleware, MaintenanceMiddleware, returns a response of its own, 503 Service Unavailable, when the setting MAINTENANCE_MODE is on,
except for the path /health/, which a load balancer keeps checking. It reads the setting in __call__, not in __init__, so
the setting can change while the process runs.
import time
import uuid
from django.conf import settings
from django.http import HttpResponse
def request_id_middleware(get_response):
def middleware(request):
request.request_id = request.headers.get('X-Request-ID') or uuid.uuid4().hex[:8]
response = get_response(request)
response['X-Request-ID'] = request.request_id
return response
return middleware
class TimingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
start = time.perf_counter()
response = self.get_response(request)
elapsed = (time.perf_counter() - start) * 1000
response['X-Duration-ms'] = f'{elapsed:.1f}'
return response
class MaintenanceMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if settings.MAINTENANCE_MODE and not request.path.startswith('/health/'):
return HttpResponse('Back soon.', status=503, headers={'Retry-After': '120'})
return self.get_response(request)
Middleware is switched on by listing it in MIDDLEWARE. We insert ours after SecurityMiddleware, which stays first, and add the
setting MAINTENANCE_MODE at the end of the settings file; the middleware from the later sections is already in the list. The order is
significant, and we return to it in the next section.
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'pages.middleware.request_id_middleware',
'pages.middleware.TimingMiddleware',
'pages.middleware.MaintenanceMiddleware',
'pages.hooks.ApiErrorMiddleware',
'pages.hooks.ViewInfoMiddleware',
'pages.modes.DebugOnlyMiddleware',
'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',
]
...
MAINTENANCE_MODE = False
The demo script prints the list, requests the index page, and toggles the maintenance mode with override_settings. The identifier in the response
is the one the view printed. A request that carries its own X-Request-ID keeps it. In maintenance mode the page returns 503 with the header
Retry-After, and /health/ still answers 200.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.test import Client
from django.test.utils import setup_test_environment
setup_test_environment()
import logging
from django.conf import settings
from django.test import override_settings
logging.getLogger('django.request').setLevel(logging.ERROR)
client = Client()
print('MIDDLEWARE:')
for path in settings.MIDDLEWARE:
print(' ', path)
print()
response = client.get('/')
print(response.status_code, response.content.decode())
print('X-Request-ID:', response['X-Request-ID'])
print('X-Duration-ms:', response['X-Duration-ms'])
print('sent X-Request-ID is kept:', client.get('/', headers={'X-Request-ID': 'abc123'})['X-Request-ID'])
print()
with override_settings(MAINTENANCE_MODE=True):
for path in ('/', '/health/'):
response = client.get(path)
print(f'maintenance, GET {path} -> {response.status_code}', response.get('Retry-After', ''))
The page and the response header have the same identifier, and the time is the duration of everything inside the middleware, in milliseconds.
$ uv run python -m demos.basics MIDDLEWARE: django.middleware.security.SecurityMiddleware pages.middleware.request_id_middleware pages.middleware.TimingMiddleware pages.middleware.MaintenanceMiddleware pages.hooks.ApiErrorMiddleware pages.hooks.ViewInfoMiddleware pages.modes.DebugOnlyMiddleware 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 200 Hello, request 05aabd1b X-Request-ID: 05aabd1b X-Duration-ms: 3.1 sent X-Request-ID is kept: abc123 maintenance, GET / -> 503 120 maintenance, GET /health/ -> 200 Service Unavailable: /
The order of middleware
The request travels from the first item of MIDDLEWARE to the last, and the response travels back. To see this we use three classes that record what
happens in a list. The base class Tracer records the call of __call__, before and after the inner call, and the hooks
process_view and process_exception, which are described in the next section. The class Gate returns a 403 response
without calling the inner layers when the query string contains stop.
from django.http import HttpResponse
TRACE = []
class Tracer:
name = '?'
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
TRACE.append(f'{self.name}: request')
response = self.get_response(request)
TRACE.append(f'{self.name}: response {response.status_code}')
return response
def process_view(self, request, view_func, view_args, view_kwargs):
TRACE.append(f'{self.name}: process_view')
def process_exception(self, request, exception):
TRACE.append(f'{self.name}: process_exception {type(exception).__name__}')
class First(Tracer):
name = 'First'
class Gate(Tracer):
name = 'Gate'
def __call__(self, request):
if 'stop' in request.GET:
TRACE.append('Gate: request, answering without the view')
return HttpResponse('Stopped', status=403)
return super().__call__(request)
class Third(Tracer):
name = 'Third'
The demo replaces MIDDLEWARE with the three tracers, in the order First, Gate, Third, and visits four URLs. Each visit gets
a fresh test client, because the chain is built when a client handles its first request.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.test import Client
from django.test.utils import setup_test_environment
setup_test_environment()
from django.test import override_settings
import logging
from pages.tracing import TRACE
logging.getLogger('django.request').setLevel(logging.CRITICAL)
TRACING = ['pages.tracing.First', 'pages.tracing.Gate', 'pages.tracing.Third']
def visit(path, **options):
TRACE.clear()
client = Client(raise_request_exception=False)
with override_settings(MIDDLEWARE=TRACING):
response = client.get(path)
print(f'GET {path} -> {response.status_code}')
for line in TRACE:
print(' ', line)
print()
visit('/plain/')
visit('/plain/?stop=1')
visit('/missing/')
visit('/crash/')
For a normal request the first layer is the first to see the request and the last to see the response. The hook process_view is called in the same order, after
the URL has been resolved and before the view runs. A short-circuit by Gate means that Third and the view are never reached, but the layers
outside of Gate still see the response, here with the status 403. This is why security-related middleware, such as authentication and CSRF
protection, stands before the code that depends on it. The hook process_exception is called in the reverse order, and it is called for Http404 too.
Even in the case of an unhandled RuntimeError every layer receives a response, with the status 500, because Django turns the exception into a response before it reaches the middleware.
$ uv run python -m demos.order GET /plain/ -> 200 First: request Gate: request Third: request First: process_view Gate: process_view Third: process_view Third: response 200 Gate: response 200 First: response 200 GET /plain/?stop=1 -> 403 First: request Gate: request, answering without the view First: response 403 GET /missing/ -> 404 First: request Gate: request Third: request First: process_view Gate: process_view Third: process_view Third: process_exception Http404 Gate: process_exception Http404 First: process_exception Http404 Third: response 404 Gate: response 404 First: response 404 GET /crash/ -> 500 First: request Gate: request Third: request First: process_view Gate: process_view Third: process_view Third: process_exception RuntimeError Gate: process_exception RuntimeError First: process_exception RuntimeError Third: response 500 Gate: response 500 First: response 500
Hooks: process_view, process_exception, process_template_response
A class-based middleware may define up to three more methods, which Django discovers by name. process_view(request, view_func, view_args, view_kwargs) is called just
before the view; it returns None to continue or a response to skip the view. It is the first place where we know which view is going to run.
process_exception(request, exception) is called when the view raises an exception; it returns None to let other layers and the default handler
decide, or a response. process_template_response(request, response) is called only for responses that have a render() method, such as TemplateResponse, after the
view has returned and before the template is rendered, so it can still change the context.
The file pages/hooks.py defines two middleware. ApiErrorMiddleware converts our exception ApiError into a JSON response with the status carried by the
exception, and ignores all other exceptions. ViewInfoMiddleware remembers the name of the view in process_view, and puts it into the context of template responses.
from django.http import JsonResponse
class ApiError(Exception):
def __init__(self, message, status=400):
super().__init__(message)
self.status = status
class ApiErrorMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
return self.get_response(request)
def process_exception(self, request, exception):
if isinstance(exception, ApiError):
return JsonResponse({'error': str(exception)}, status=exception.status)
return None
class ViewInfoMiddleware:
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__}'
return None
def process_template_response(self, request, response):
response.context_data['view_name'] = request.view_name
return response
The demo visits the article page, whose template shows the name added by the middleware, and the three failing views. The last lines remove
ApiErrorMiddleware from the list to see the status without it.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.test import Client
from django.test.utils import setup_test_environment
setup_test_environment()
import logging
from django.conf import settings
from django.test import override_settings
logging.getLogger('django.request').setLevel(logging.CRITICAL)
client = Client(raise_request_exception=False)
print(client.get('/articles/middleware/').content.decode().strip())
print()
for path in ('/boom/', '/crash/', '/missing/'):
response = client.get(path)
print(f'GET {path} -> {response.status_code}', response['Content-Type'])
print('body of /boom/:', client.get('/boom/').content.decode())
print()
without = [m for m in settings.MIDDLEWARE if 'ApiErrorMiddleware' not in m]
with override_settings(MIDDLEWARE=without):
response = Client(raise_request_exception=False).get('/boom/')
print('without ApiErrorMiddleware: GET /boom/ ->', response.status_code)
The template prints the name of the view although the view did not pass it. The view boom gets the JSON answer with the status 429, while the same exception
without the middleware produces the status 500. The other exceptions are untouched: Http404 leads to the 404 page and the RuntimeError to the 500 page.
The test client usually re-raises exceptions from the view; raise_request_exception=False makes it return the 500 response, as a real server would.
$ uv run python -m demos.hooks
<h1>middleware</h1>
<p>Rendered by pages.views.article</p>
GET /boom/ -> 429 application/json
GET /crash/ -> 500 text/html
GET /missing/ -> 404 text/html; charset=utf-8
body of /boom/: {"error": "the quota is exhausted"}
without ApiErrorMiddleware: GET /boom/ -> 500
MiddlewareNotUsed and async middleware
A middleware can remove itself from the chain. If __init__ raises MiddlewareNotUsed, Django skips it, logs the reason when DEBUG is on,
and builds the chain without it. This is useful for middleware that depends on a setting or an installed package. The class DebugOnlyMiddleware is used only when
DEBUG is true.
Django can run under WSGI, synchronously, and under ASGI, asynchronously. A middleware that does not say anything about it is treated as synchronous, and Django adapts it, which costs a
thread switch in an async project. A middleware that wants to work in both modes is decorated with sync_and_async_middleware (for a class there are the attributes
sync_capable and async_capable), and it checks whether get_response is a coroutine function to decide which inner function to return. The function label_middleware sets the header
X-Mode in the way that matches the mode.
from asyncio import iscoroutinefunction
from django.conf import settings
from django.core.exceptions import MiddlewareNotUsed
from django.utils.decorators import sync_and_async_middleware
class DebugOnlyMiddleware:
def __init__(self, get_response):
if not settings.DEBUG:
raise MiddlewareNotUsed('only for development')
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
response['X-Debug-Mode'] = 'on'
return response
@sync_and_async_middleware
def label_middleware(get_response):
if iscoroutinefunction(get_response):
async def middleware(request):
response = await get_response(request)
response['X-Mode'] = 'async'
return response
else:
def middleware(request):
response = get_response(request)
response['X-Mode'] = 'sync'
return response
return middleware
The demo creates test clients under different settings. The chain is built by the client when it handles its first request, so a new client reads the changed value of DEBUG.
The synchronous Client and the AsyncClient call the same decorated function and get the two variants.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.test import Client
from django.test.utils import setup_test_environment
setup_test_environment()
import asyncio
from django.test import AsyncClient, override_settings
print('DEBUG=True :', Client().get('/plain/').get('X-Debug-Mode'))
with override_settings(DEBUG=False):
print('DEBUG=False:', Client().get('/plain/').get('X-Debug-Mode'))
print()
with override_settings(MIDDLEWARE=['pages.modes.label_middleware']):
print('sync client :', Client().get('/plain/')['X-Mode'])
print('async client:', asyncio.run(AsyncClient().get('/plain/'))['X-Mode'])
The middleware that depends on DEBUG is active only while DEBUG is true, and the dual-mode middleware reports the mode of the request that it
served.
$ uv run python -m demos.modes DEBUG=True : on DEBUG=False: None sync client : sync async client: async
The built-in middleware
The list that startproject generates contains the middleware that most projects need. SecurityMiddleware adds security headers and can redirect to HTTPS.
SessionMiddleware and AuthenticationMiddleware set request.session and request.user; CommonMiddleware does the
APPEND_SLASH redirect; CsrfViewMiddleware rejects unsafe requests without a token; MessageMiddleware supports the messages framework; and
XFrameOptionsMiddleware sets X-Frame-Options against clickjacking. Optional ones include GZipMiddleware, ConditionalGetMiddleware
and LocaleMiddleware.
SecurityMiddleware is controlled by settings with the prefix SECURE_. It reads them once, when it is created, unlike the maintenance middleware, so the demo uses a new client
for each combination. By default it sets X-Content-Type-Options, Referrer-Policy, and Cross-Origin-Opener-Policy. SECURE_SSL_REDIRECT sends plain HTTP requests to HTTPS with a permanent redirect, and
SECURE_HSTS_SECONDS adds Strict-Transport-Security, but only to responses to HTTPS requests. The last test removes XFrameOptionsMiddleware to show that the header
X-Frame-Options belongs to it.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.test import Client
from django.test.utils import setup_test_environment
setup_test_environment()
from django.conf import settings
from django.test import override_settings
client = Client()
names = ('Strict-Transport-Security', 'X-Content-Type-Options', 'Referrer-Policy',
'Cross-Origin-Opener-Policy', 'X-Frame-Options')
def show(label, response):
print(label, '->', response.status_code, response.get('Location', ''))
for name in names:
if name in response:
print(f' {name}: {response[name]}')
show('defaults', client.get('/'))
print()
with override_settings(SECURE_HSTS_SECONDS=31536000, SECURE_HSTS_INCLUDE_SUBDOMAINS=True,
SECURE_SSL_REDIRECT=True):
client = Client()
show('SECURE_SSL_REDIRECT, http', client.get('/'))
show('SECURE_SSL_REDIRECT, https', client.get('/', secure=True))
print()
with override_settings(MIDDLEWARE=[m for m in settings.MIDDLEWARE if 'XFrame' not in m]):
show('without XFrameOptionsMiddleware', Client().get('/'))
The HTTP request is redirected to the HTTPS address, and the HSTS header appears only on the secure response. Before enabling HSTS in production, make sure that HTTPS works for all subdomains, because
browsers remember the policy for the given time. The command manage.py check --deploy lists such settings.
$ uv run python -m demos.security defaults -> 200 X-Content-Type-Options: nosniff Referrer-Policy: same-origin Cross-Origin-Opener-Policy: same-origin X-Frame-Options: DENY SECURE_SSL_REDIRECT, http -> 301 https://testserver/ X-Content-Type-Options: nosniff Referrer-Policy: same-origin Cross-Origin-Opener-Policy: same-origin SECURE_SSL_REDIRECT, https -> 200 Strict-Transport-Security: max-age=31536000; includeSubDomains X-Content-Type-Options: nosniff Referrer-Policy: same-origin Cross-Origin-Opener-Policy: same-origin X-Frame-Options: DENY without XFrameOptionsMiddleware -> 200 X-Content-Type-Options: nosniff Referrer-Policy: same-origin Cross-Origin-Opener-Policy: same-origin
Testing middleware
A middleware that only wraps get_response is easy to test as a plain function or object. We pass a fake get_response and a request from RequestFactory, so no URL
configuration or database is involved, which also lets us check that a short-circuiting middleware does not call the inner layer. The integration tests use the test client and see the full chain.
from django.http import HttpResponse
from django.test import RequestFactory, SimpleTestCase, TestCase, override_settings
from .middleware import MaintenanceMiddleware, request_id_middleware
class MiddlewareUnitTests(SimpleTestCase):
def test_request_id_is_generated(self):
middleware = request_id_middleware(lambda request: HttpResponse())
request = RequestFactory().get('/')
response = middleware(request)
self.assertEqual(response['X-Request-ID'], request.request_id)
self.assertEqual(len(request.request_id), 8)
def test_request_id_is_kept(self):
middleware = request_id_middleware(lambda request: HttpResponse())
request = RequestFactory().get('/', headers={'X-Request-ID': 'abc'})
self.assertEqual(middleware(request)['X-Request-ID'], 'abc')
@override_settings(MAINTENANCE_MODE=True)
def test_maintenance_does_not_call_the_view(self):
calls = []
middleware = MaintenanceMiddleware(lambda request: calls.append(request))
response = middleware(RequestFactory().get('/'))
self.assertEqual(response.status_code, 503)
self.assertEqual(calls, [])
class MiddlewareIntegrationTests(TestCase):
def test_headers(self):
response = self.client.get('/')
self.assertEqual(len(response['X-Request-ID']), 8)
self.assertIn('X-Duration-ms', response)
def test_api_error_becomes_json(self):
response = self.client.get('/boom/')
self.assertEqual(response.status_code, 429)
self.assertEqual(response.json(), {'error': 'the quota is exhausted'})
def test_template_response_context(self):
response = self.client.get('/articles/middleware/')
self.assertContains(response, 'Rendered by pages.views.article')
@override_settings(MAINTENANCE_MODE=True)
def test_maintenance(self):
response = self.client.get('/')
self.assertEqual(response.status_code, 503)
self.assertEqual(response['Retry-After'], '120')
The unit tests check the generated and the preserved identifier and the short-circuit of the maintenance mode. The integration tests check the headers, the JSON error, the context added by the hook,
and the maintenance page, with the settings changed by the decorator override_settings. All of them pass.
$ uv run python manage.py test Found 7 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). ....... ---------------------------------------------------------------------- Ran 7 tests in 0.006s OK Destroying test database for alias 'default'...
Source
Django documentation: Middleware
Django documentation: Middleware reference
Django documentation: MIDDLEWARE setting
In this article we have written function-based and class-based middleware, followed the request and the response through the chain, used the hooks
process_view, process_exception, and process_template_response, written a middleware for both sync and async mode, configured SecurityMiddleware, and tested the code. Related articles are
Django views, Django HttpResponse, and
Django sessions and cookies.
Author
List all Django tutorials.