Writing Custom Middleware in Django
Build custom Django middleware — request/response processing, exception handling, middleware ordering, and real-world examples.
What you'll learn
- ✓How Django middleware processes requests and responses
- ✓How to write function-based and class-based middleware
- ✓How to add custom headers, logging, and rate limiting
- ✓How middleware ordering affects behavior
- ✓How to use process_exception and process_view hooks
Prerequisites
- •A working Django project
- •Basic understanding of HTTP request/response cycle
Middleware sits between the web server and your views. Every request passes through every middleware before reaching a view, and every response passes back through on the way out. Authentication, session handling, CSRF protection, security headers — Django’s built-in middleware handles all of these.
Custom middleware lets you add cross-cutting concerns to your application: request logging, performance timing, IP-based throttling, tenant detection, or custom headers. This guide covers the patterns you need.
How Middleware Works
Django’s middleware stack is an onion. Requests travel inward through each middleware’s request phase, hit the view, and then travel outward through each middleware’s response phase.
Request → Middleware 1 (request) → Middleware 2 (request) → View
Response ← Middleware 1 (response) ← Middleware 2 (response) ← View
Middleware is defined in settings.MIDDLEWARE as a list. Order matters — middleware at the top processes requests first and responses last.
Function-Based Middleware
The simplest form. A function that takes get_response and returns a middleware callable:
# myapp/middleware.py
def simple_timing_middleware(get_response):
"""Log how long each request takes."""
import time
import logging
logger = logging.getLogger("django.request")
def middleware(request):
start = time.monotonic()
response = get_response(request) # Call the next middleware / view
duration = time.monotonic() - start
logger.info(
"%s %s completed in %.3fs (status %d)",
request.method,
request.path,
duration,
response.status_code,
)
return response
return middleware
Everything before get_response(request) runs during the request phase. Everything after runs during the response phase.
Class-Based Middleware
For more complex middleware, use a class. The __init__ method runs once at startup, __call__ runs on every request:
# myapp/middleware.py
import time
import logging
logger = logging.getLogger("django.request")
class RequestTimingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
# One-time setup — runs when the server starts
def __call__(self, request):
# Request phase
request._start_time = time.monotonic()
response = self.get_response(request)
# Response phase
duration = time.monotonic() - request._start_time
response["X-Request-Duration"] = f"{duration:.3f}s"
return response
Registering Middleware
Add your middleware to settings.MIDDLEWARE:
# 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",
# Your custom middleware
"myapp.middleware.RequestTimingMiddleware",
]
Place your middleware after Django’s built-in middleware unless you have a specific reason to go earlier. For example, a logging middleware should come early to capture all requests, even those rejected by SecurityMiddleware.
Practical Examples
Request ID Middleware
Assign every request a unique ID for tracing across logs:
import uuid
class RequestIDMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Check if the client already sent a request ID
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
request.request_id = request_id
response = self.get_response(request)
# Echo the request ID back in the response
response["X-Request-ID"] = request_id
return response
Use request.request_id in views and logging:
import logging
logger = logging.getLogger(__name__)
def my_view(request):
logger.info("Processing request", extra={"request_id": request.request_id})
...
IP-Based Rate Limiting
A simple rate limiter using Django’s cache:
from django.core.cache import cache
from django.http import JsonResponse
class RateLimitMiddleware:
def __init__(self, get_response):
self.get_response = get_response
self.rate_limit = 100 # requests per window
self.window = 60 # seconds
def __call__(self, request):
if request.path.startswith("/api/"):
ip = self.get_client_ip(request)
cache_key = f"ratelimit:{ip}"
requests_made = cache.get(cache_key, 0)
if requests_made >= self.rate_limit:
return JsonResponse(
{"error": "Rate limit exceeded. Try again later."},
status=429,
)
cache.set(cache_key, requests_made + 1, timeout=self.window)
response = self.get_response(request)
response["X-RateLimit-Remaining"] = str(
self.rate_limit - requests_made - 1
)
return response
return self.get_response(request)
@staticmethod
def get_client_ip(request):
x_forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded:
return x_forwarded.split(",")[0].strip()
return request.META.get("REMOTE_ADDR")
Force JSON Content-Type for API Routes
from django.http import JsonResponse
class RequireJSONMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if (
request.path.startswith("/api/")
and request.method in ("POST", "PUT", "PATCH")
and request.content_type != "application/json"
):
return JsonResponse(
{"error": "Content-Type must be application/json"},
status=415,
)
return self.get_response(request)
The process_view Hook
process_view runs after URL routing but before the view executes. It receives the view function and its arguments:
class AuditMiddleware:
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):
"""Log which view is handling this request."""
import logging
logger = logging.getLogger("audit")
logger.info(
"View: %s.%s | User: %s | Path: %s",
view_func.__module__,
view_func.__name__,
getattr(request, "user", "anonymous"),
request.path,
)
return None # Return None to continue processing
Returning None means “continue normally”. Returning an HttpResponse short-circuits the remaining middleware and view.
The process_exception Hook
process_exception runs when a view raises an unhandled exception:
import logging
import traceback
logger = logging.getLogger("django.request")
class ExceptionLoggingMiddleware:
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):
logger.error(
"Unhandled exception on %s %s: %s\n%s",
request.method,
request.path,
str(exception),
traceback.format_exc(),
extra={"request_id": getattr(request, "request_id", "unknown")},
)
return None # Let Django's default error handling continue
Middleware Ordering Guidelines
Order matters for correctness:
- SecurityMiddleware — first, to enforce HTTPS and HSTS
- SessionMiddleware — before anything that uses
request.session - AuthenticationMiddleware — after sessions, before any auth checks
- Your custom middleware — after Django’s core middleware, unless you need early access
If middleware A depends on data set by middleware B, B must come first in the MIDDLEWARE list.
Middleware vs Decorators
Use middleware for logic that applies to all requests or broad URL patterns. Use decorators (@login_required, @cache_page) for per-view logic.
# Decorator — applies to one view
@login_required
def dashboard(request):
...
# Middleware — applies to all /admin/ routes
class AdminIPRestrictMiddleware:
def __call__(self, request):
if request.path.startswith("/admin/"):
# Check IP whitelist
...
return self.get_response(request)
Testing Middleware
Test middleware by using Django’s test client, which runs the full middleware stack:
from django.test import TestCase, RequestFactory
from myapp.middleware import RequestIDMiddleware
class RequestIDMiddlewareTest(TestCase):
def test_adds_request_id_header(self):
response = self.client.get("/")
self.assertIn("X-Request-ID", response)
def test_uses_provided_request_id(self):
response = self.client.get("/", HTTP_X_REQUEST_ID="custom-id-123")
self.assertEqual(response["X-Request-ID"], "custom-id-123")
Summary
Custom middleware is Django’s mechanism for cross-cutting concerns. Write function-based middleware for simple cases and class-based for complex ones. Use process_view for pre-view logic and process_exception for error handling. Keep middleware focused on a single responsibility, and be mindful of ordering in settings.MIDDLEWARE.
Related articles
- Django Django + Celery: Background Tasks and Task Queues
Integrate Celery with Django for background task processing — setup, writing tasks, retries, periodic tasks, and monitoring with Flower.
- Django Django Custom Managers and Chainable QuerySets
Build custom managers and chainable QuerySets in Django to encapsulate query logic, keep views thin, and write expressive ORM code.
- Django Django Multi-Tenancy: Shared Database Schema Patterns
Implement multi-tenancy in Django using shared database schemas — tenant models, middleware, filtered QuerySets, and data isolation.
- Django DRF ViewSets and Routers: Clean API Endpoints Fast
Build REST APIs with Django REST Framework ViewSets and Routers — ModelViewSet, custom actions, filtering, and URL generation.