Django middleware explained: what developers should know
Django middleware sits between your web server and your view logic, intercepting every request and response that flows through your application. Understanding how middleware works—and knowing when to use it versus other patterns—can transform how you handle cross-cutting concerns like authentication, logging, and request tracking. In this guide, we'll walk through the mechanics of middleware, show you production patterns we've used across trading platforms and SaaS applications, and help you avoid the pitfalls that catch most teams when they scale.
How Django middleware works
Middleware in Django is a series of hooks that process requests and responses in a specific order. When a request comes in, Django passes it through each middleware's process_request method (or equivalent) from top to bottom. Once a view executes, the response flows back through each middleware's process_response method in reverse order, from bottom to top.
This layered architecture means middleware can:
- Modify the incoming request before it reaches your view
- Short-circuit the request chain and return early
- Transform or augment the response before it leaves your application
- Execute code during exception handling
Think of middleware as a series of concentric rings. Each ring processes the request on the way in and the response on the way out. Understanding this flow is critical when you have multiple middleware components stacked together.
Order in MIDDLEWARE matters
The order you declare middleware in your Django settings is not cosmetic—it determines the execution sequence and can make or break your application logic. Middleware executes top-to-bottom on the way in, and bottom-to-top on the way out. This means earlier middleware sees the request first and wraps later middleware on the response side.
In production systems handling high-volume requests, we've seen ordering mistakes cause silent failures and difficult-to-debug race conditions. Here's a safe ordering pattern we use:
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.middleware.authentication.AuthenticationMiddleware',
# Custom logging/request ID middleware
'myapp.middleware.RequestIdMiddleware',
'myapp.middleware.RequestTimingMiddleware',
# Custom auth/permission middleware
'myapp.middleware.TenantMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]The key principles:
- Security middleware goes first (CSRF, clickjacking)
- Authentication middleware before your custom auth logic
- Request tracking and timing after authentication but before view-specific logic
- Tenant or multi-org middleware in its own layer, after you know who the user is
We once inherited a system where tenant middleware ran before authentication. Every request spent milliseconds checking tenant context before we even knew if the user was valid. Moving it lower in the stack cut database query overhead by 15%.
Use for request IDs, timing, and headers
Three of the most valuable uses for middleware are tracking request IDs, measuring request duration, and setting standard response headers. In a distributed system, a request ID ties logs across services and makes debugging production issues infinitely easier.
Here's a real-world example from a ride-hailing platform:
import uuid
import time
import logging
logger = logging.getLogger(__name__)
class RequestIdMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Generate or extract request ID
request_id = request.headers.get('X-Request-ID', str(uuid.uuid4()))
request.id = request_id
# Store in a thread-local or context variable for logging
request.start_time = time.time()
response = self.get_response(request)
# Add request ID to response headers
response['X-Request-ID'] = request_id
return response
class RequestTimingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
start = time.time()
response = self.get_response(request)
duration = time.time() - start
response['X-Response-Time'] = str(duration)
# Log slow requests
if duration > 1.0:
logger.warning(
f"Slow request: {request.method} {request.path} took {duration:.2f}s",
extra={'request_id': getattr(request, 'id', 'unknown')}
)
return responseIn our e-commerce platform, adding request IDs cut incident resolution time by 40% because we could instantly correlate frontend errors, API logs, and database queries. The timing middleware helped us spot which endpoints were degrading before they affected user experience.
A few production notes:
- Use
request.headers.get()to respect upstream load balancers that may already have set a request ID - Store the request ID in a context variable (like
contextvars) so it automatically flows through logging and async code - Never block on middleware operations like database writes; queue them or use async if needed
Keep middleware fast
Middleware runs for every single request, so performance compounds quickly. A middleware function that takes 10ms will add 10 seconds of latency to a service handling 1000 requests per second. We've seen production incidents where a single slow middleware call cascaded into cascading timeouts across a system.
Practical rules we follow:
- Avoid database queries in middleware unless absolutely necessary
- Cache any configuration or lookups (use Django's cache framework or memory-safe caching)
- Never make external HTTP calls in middleware synchronously
- Profile middleware in staging with realistic load before deploying to production
- Consider using
process_exceptiononly for exception handling that won't block the response path
In a travel booking platform, we had middleware that checked user permissions by querying a permissions table. During peak booking times, this middleware became a bottleneck. We switched to caching the permission set in-memory with a 5-minute TTL, and latency dropped from 150ms to 5ms per request. The tradeoff was acceptable for that use case.
If you find yourself writing middleware that needs to do heavy lifting, that's usually a sign you should use Django signals or decorators on specific views instead.
Prefer decorators for per-view logic
Middleware applies globally to every request. If you only need to run logic on certain views, use view decorators or class-based view mixins instead. This keeps your middleware focused and your logic close to where it applies.
Here's when to use each pattern:
- Middleware: CORS headers, request IDs, security headers, authentication context setup
- Decorators: Rate limiting on specific endpoints, extra permission checks, feature flags for certain views
- Class-based mixins: Shared logic across multiple views in the same class hierarchy
An example: instead of checking rate limits in middleware (which runs for every request), use a decorator on the endpoints that need it:
from functools import wraps
from django.core.cache import cache
from django.http import HttpResponse
def rate_limit(calls_per_minute):
def decorator(view_func):
@wraps(view_func)
def wrapper(request, *args, **kwargs):
key = f"rate_limit:{request.user.id}"
current = cache.get(key, 0)
if current >= calls_per_minute:
return HttpResponse("Rate limit exceeded", status=429)
cache.set(key, current + 1, 60)
return view_func(request, *args, **kwargs)
return wrapper
return decorator
# Usage
@rate_limit(calls_per_minute=100)
def api_endpoint(request):
# Your view logic
passThis approach is cleaner, faster, and makes it obvious which endpoints have rate limiting. You avoid running rate-limit checks on static files, health checks, or other endpoints that don't need them.
Common mistakes
Mistake 1: Modifying request.path or request.method in middleware
Django caches some request properties. If you modify request.path in middleware, downstream code may not see your changes. Use a custom attribute instead: request.custom_path = ...
Mistake 2: Blocking on I/O in middleware
A database query, API call, or file operation in middleware will block the entire request. We once saw a middleware that made a synchronous call to an external auth service. When that service was slow, the entire application hung. Use async middleware if you must do I/O, or defer the work to a queue.
Mistake 3: Forgetting that middleware wraps exceptions
If your middleware doesn't properly handle exceptions or call super().__call__(request) correctly, you can swallow errors. Always test error paths in middleware.
Mistake 4: Putting business logic in middleware
Middleware should be boring. If you're writing application-specific logic, it belongs in a view, a service layer, or a signal handler. Middleware is for plumbing.
Key takeaways
- Middleware order matters: security and authentication middleware go early; custom tracking and tenant logic go later.
- Use middleware for cross-cutting concerns that apply to every request: request IDs, timing, headers, security.
- Keep middleware code fast and simple; cache when possible and avoid I/O.
- Prefer decorators and signals for view-specific or feature-specific logic.
- Test middleware thoroughly with realistic load before deploying to production.
- Always respect the middleware execution order (request top-to-bottom, response bottom-to-top) when designing your stack.






Comments
Loading comments…