When I first started with Django, I completely ignored middleware. I thought - who cares about some background code? Then I spent three days debugging a production issue where sessions weren't working properly. Turns out, I had misconfigured the session middleware. That's when I realized middleware isn't optional - it's the backbone of every Django request-response cycle.
In this chapter, I'll show you what middleware actually does, how to use the built-in ones effectively, and when to write your own. Trust me, understanding middleware will save you countless hours of debugging later.
10.1 How Middleware Works: Request/Response Lifecycle
Think of middleware as a series of checkpoints. Every request passes through each middleware before reaching your view. Every response passes through them again on the way back to the user.
# Here's what actually happens when a user visits your site:
"""
User Request →
Middleware 1 (process_request) →
Middleware 2 (process_request) →
... →
URL Resolver →
View →
Response →
Middleware N (process_response) →
... →
User Browser
"""
# Let me show you with a real example
# middlewares.py
import time
from django.utils.timezone import now
class RequestTimingMiddleware:
"""Measures how long each request takes - super useful for finding slow pages"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# This code runs BEFORE the view
request.start_time = time.time()
# Call the next middleware or view
response = self.get_response(request)
# This code runs AFTER the view
duration = time.time() - request.start_time
# Add timing info to response headers (visible in browser dev tools)
response['X-Request-Duration'] = f"{duration:.3f}s"
# Log slow requests (more than 2 seconds)
if duration > 2:
print(f"Slow request: {request.path} took {duration:.2f} seconds")
return response
# Understanding the execution order
class DebugMiddleware:
def __init__(self, get_response):
self.get_response = get_response
print("Middleware initialized when server starts")
def __call__(self, request):
print(f"1. Before view - Path: {request.path}")
response = self.get_response(request)
print(f"3. After view - Status: {response.status_code}")
return response
# You can also have separate methods for each phase
class DetailedMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def process_request(self, request):
# Runs before view
print(f"Request received: {request.method} {request.path}")
# Return None to continue, return HttpResponse to short-circuit
return None
def process_view(self, request, view_func, view_args, view_kwargs):
# Runs after URL resolution but before view
print(f"View function: {view_func.__name__}")
return None
def process_exception(self, request, exception):
# Runs if view raises an exception
print(f"Exception caught: {exception}")
# Return HttpResponse to handle error, return None to propagate
return None
def process_response(self, request, response):
# Runs after view (always)
print(f"Response sent: {response.status_code}")
return response
From my debugging sessions: The most confusing part for beginners is that middleware order matters. If you put a middleware that needs user authentication BEFORE the authentication middleware, it won't work. I've made this mistake more times than I'd like to admit.
10.2 Built-in Middleware (Security, Session, CSRF, GZip)
Django comes with about a dozen middleware classes enabled by default. Each serves a specific purpose. Let me explain what each one actually does.
# settings.py - Default middleware (in order)
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',
]
# Let me break down what each one does:
# 1. SecurityMiddleware - HTTPS and security headers
# Forces HTTPS, sets HSTS headers, handles redirects
# Example configuration:
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_BROWSER_XSS_FILTER = True
# 2. SessionMiddleware - Manages user sessions
# Adds request.session dictionary
# Stores session data (default: database, can use cache or cookies)
SESSION_ENGINE = 'django.contrib.sessions.backends.db'
SESSION_COOKIE_AGE = 1209600 # 2 weeks in seconds
SESSION_EXPIRE_AT_BROWSER_CLOSE = False
# 3. CommonMiddleware - URL rewriting and other common tasks
# Handles trailing slashes (APPEND_SLASH=True by default)
# Sets Content-Length headers
APPEND_SLASH = True
PREPEND_WWW = False
# 4. CsrfViewMiddleware - Protects against CSRF attacks
# Adds {% csrf_token %} requirement to POST forms
# Generates and validates CSRF tokens
CSRF_COOKIE_SECURE = True
CSRF_USE_SESSIONS = False
CSRF_TRUSTED_ORIGINS = ['https://mysite.com', 'https://api.mysite.com']
# 5. AuthenticationMiddleware - Adds user to request
# Makes request.user available in views and templates
# Sets request.user.is_authenticated
# 6. MessageMiddleware - Flash messages system
# Enables messages.success(), messages.error(), etc.
from django.contrib import messages
messages.success(request, "Profile updated!")
# 7. XFrameOptionsMiddleware - Clickjacking protection
# Prevents your site from being embedded in iframes
X_FRAME_OPTIONS = 'DENY' # Or 'SAMEORIGIN'
# Additional useful built-in middleware not enabled by default:
# GZipMiddleware - Compresses responses (faster page loads)
MIDDLEWARE.append('django.middleware.gzip.GZipMiddleware')
# Only compress if response is larger than 200 bytes
GZIP_COMPRESSION_LEVEL = 6 # 1-9, 9 is highest compression
# ConditionalGetMiddleware - Adds ETag and Last-Modified headers
MIDDLEWARE.append('django.middleware.http.ConditionalGetMiddleware')
# LocaleMiddleware - Internationalization
MIDDLEWARE.append('django.middleware.locale.LocaleMiddleware')
# Must come after SessionMiddleware, before CommonMiddleware
What took me years to understand: The order in MIDDLEWARE list is the order of execution. SecurityMiddleware should be first because you want security checks before anything else. SessionMiddleware needs to be before AuthenticationMiddleware because auth needs sessions. Get the order wrong and things break mysteriously.
10.3 Writing Custom Middleware for Logging, Headers, and IP Blocking
Here's where middleware becomes really useful. I've built dozens of custom middleware for different projects. Let me share the most useful ones.
# middlewares.py - Real-world custom middleware examples
# 1. Request logging middleware (essential for debugging production issues)
import logging
import json
from django.utils.deprecation import MiddlewareMixin
logger = logging.getLogger(__name__)
class RequestLoggingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Log incoming request
log_data = {
'method': request.method,
'path': request.path,
'ip': self.get_client_ip(request),
'user_agent': request.META.get('HTTP_USER_AGENT', 'unknown'),
'user': str(request.user) if request.user.is_authenticated else 'anonymous',
}
# Log POST data (excluding passwords)
if request.method == 'POST':
safe_post = {k: v for k, v in request.POST.items() if 'password' not in k.lower()}
log_data['post_data'] = safe_post
logger.info(f"Request: {json.dumps(log_data)}")
response = self.get_response(request)
# Log response
logger.info(f"Response: {request.path} - Status {response.status_code}")
return response
def get_client_ip(self, request):
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
# 2. IP blocking middleware (block bots or specific regions)
class IPBlockMiddleware:
def __init__(self, get_response):
self.get_response = get_response
# Load blocked IPs from settings or database
self.blocked_ips = getattr(settings, 'BLOCKED_IPS', [])
self.blocked_subnets = getattr(settings, 'BLOCKED_SUBNETS', [])
def __call__(self, request):
client_ip = self.get_client_ip(request)
# Check exact IP match
if client_ip in self.blocked_ips:
return self.blocked_response(request)
# Check subnet matches (e.g., 192.168.1.0/24)
for subnet in self.blocked_subnets:
if self.ip_in_subnet(client_ip, subnet):
return self.blocked_response(request)
return self.get_response(request)
def get_client_ip(self, request):
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
return x_forwarded_for.split(',')[0]
return request.META.get('REMOTE_ADDR')
def ip_in_subnet(self, ip, subnet):
# Simplified subnet check - use ipaddress module in production
return False
def blocked_response(self, request):
from django.http import HttpResponseForbidden
return HttpResponseForbidden("Access denied from your location")
# 3. Custom security headers middleware
class SecurityHeadersMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
# Add security headers that Django doesn't set by default
response['X-Content-Type-Options'] = 'nosniff'
response['X-Frame-Options'] = 'DENY'
response['Referrer-Policy'] = 'strict-origin-when-cross-origin'
response['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()'
# Content Security Policy (CSP) - very important for XSS protection
response['Content-Security-Policy'] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: https:; "
"font-src 'self'; "
"connect-src 'self'"
)
return response
# 4. Maintenance mode middleware
from django.shortcuts import render
from django.conf import settings
class MaintenanceModeMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Check if maintenance mode is enabled
if getattr(settings, 'MAINTENANCE_MODE', False):
# Allow admins to bypass
if request.user.is_authenticated and request.user.is_staff:
return self.get_response(request)
# Allow specific IPs to bypass
client_ip = self.get_client_ip(request)
if client_ip in getattr(settings, 'MAINTENANCE_ALLOWED_IPS', []):
return self.get_response(request)
# Show maintenance page
return render(request, 'maintenance.html', status=503)
return self.get_response(request)
def get_client_ip(self, request):
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
return x_forwarded_for.split(',')[0]
return request.META.get('REMOTE_ADDR')
# settings.py - Configuration for custom middleware
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'myapp.middlewares.RequestLoggingMiddleware', # Add first to log everything
'myapp.middlewares.IPBlockMiddleware', # Block before any processing
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'myapp.middlewares.SecurityHeadersMiddleware', # Add headers at the end
'myapp.middlewares.MaintenanceModeMiddleware', # Last to override everything
]
# Configuration variables
BLOCKED_IPS = ['192.168.1.100', '10.0.0.5']
BLOCKED_SUBNETS = ['192.168.0.0/16']
MAINTENANCE_MODE = False
MAINTENANCE_ALLOWED_IPS = ['127.0.0.1']
Real story: I once had a client whose site was getting hammered by bots from a specific country. Instead of installing a complex package, I wrote a 30-line IP blocking middleware. It blocked the offending IP range and the site recovered instantly. Sometimes simple solutions are best.
10.4 Middleware Order and Performance Impact
Order matters more than you think. Let me show you the right way to organize middleware and how to avoid performance pitfalls.
# The correct middleware order pattern (from Django documentation)
MIDDLEWARE = [
# 1. Performance & security (should be first)
'django.middleware.gzip.GZipMiddleware', # Compress responses early
'django.middleware.security.SecurityMiddleware',
# 2. Session (needed for auth and messages)
'django.contrib.sessions.middleware.SessionMiddleware',
# 3. Locale & common (after session)
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
# 4. CSRF (needs session)
'django.middleware.csrf.CsrfViewMiddleware',
# 5. Authentication (needs session)
'django.contrib.auth.middleware.AuthenticationMiddleware',
# 6. Messages (needs session and auth)
'django.contrib.messages.middleware.MessageMiddleware',
# 7. Custom middleware (ordered by dependency)
'myapp.middlewares.RequestLoggingMiddleware', # Should be early to catch everything
'myapp.middlewares.MaintenanceModeMiddleware', # Should be early to override
'myapp.middlewares.SecurityHeadersMiddleware', # Should be last
]
# Performance considerations:
# Bad: Middleware that does heavy work on every request
class SlowMiddleware:
def __call__(self, request):
# Don't do this!
import time
time.sleep(0.5) # Every request takes half a second longer
# Don't query database unnecessarily
all_users = User.objects.all() # Even if not needed
return self.get_response(request)
# Good: Middleware with early exits
class EfficientMiddleware:
def __call__(self, request):
# Skip processing for static files
if request.path.startswith('/static/'):
return self.get_response(request)
# Skip for admin
if request.path.startswith('/admin/'):
return self.get_response(request)
# Do heavy work only when needed
if request.method == 'POST':
self.process_post_data(request)
return self.get_response(request)
def process_post_data(self, request):
# Process only POST requests
pass
# Using caching in middleware
from django.core.cache import cache
class CachedHeaderMiddleware:
def __call__(self, request):
cache_key = f'headers_{request.path}'
cached_response = cache.get(cache_key)
if cached_response:
return cached_response
response = self.get_response(request)
# Cache expensive header generation
if response.status_code == 200:
cache.set(cache_key, response, 60) # Cache for 1 minute
return response
# Measuring middleware performance
class PerformanceMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
import time
start = time.time()
response = self.get_response(request)
duration = time.time() - start
# Log slow middleware chains
if duration > 1:
print(f"Slow request: {request.path} took {duration:.2f}s")
# You could add this to response headers for debugging
response['X-Slow-Request'] = 'true'
return response
Performance lesson learned: I once added a middleware that checked every request against a database table of blocked IPs. Sounds reasonable, right? But the table had 50,000 entries and we were getting 100 requests per second. The database couldn't keep up. Now I always ask: "Does this check need to happen on every request?" If yes, I use Redis or cache the result.
10.5 Using Middleware for Multi-tenant Applications
Multi-tenancy is when a single Django application serves multiple customers (tenants) with isolated data. Middleware is perfect for identifying which tenant is making the request.
# Multi-tenant middleware example
# models.py
from django.db import models
class Tenant(models.Model):
name = models.CharField(max_length=100)
subdomain = models.CharField(max_length=100, unique=True)
schema_name = models.CharField(max_length=63, unique=True)
def __str__(self):
return self.name
class TenantAwareModel(models.Model):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
class Meta:
abstract = True
# middlewares.py
from django.db import connection
from django.http import Http404
class TenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Extract subdomain from host header
host = request.get_host().split(':')[0] # Remove port
subdomain = host.split('.')[0]
# Special handling for www and root domain
if subdomain in ['www', 'localhost', '127.0.0.1']:
subdomain = None
# Store tenant in request for use in views
if subdomain:
try:
request.tenant = Tenant.objects.get(subdomain=subdomain)
# Set schema for PostgreSQL schemas (if using django-tenants)
connection.set_schema(request.tenant.schema_name)
except Tenant.DoesNotExist:
raise Http404("Tenant not found")
else:
request.tenant = None
response = self.get_response(request)
# Clean up
if hasattr(request, 'tenant') and request.tenant:
connection.set_schema('public')
return response
# Now in views, you can do:
def tenant_dashboard(request):
tenant = request.tenant
# All queries automatically filtered by tenant
products = Product.objects.filter(tenant=tenant)
return render(request, 'dashboard.html', {'tenant': tenant, 'products': products})
# Automatic tenant filtering using custom manager
class TenantManager(models.Manager):
def get_queryset(self):
from django.http import HttpRequest
request = get_current_request() # You'd need to implement this
if request and hasattr(request, 'tenant'):
return super().get_queryset().filter(tenant=request.tenant)
return super().get_queryset()
class Product(models.Model):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
name = models.CharField(max_length=100)
objects = TenantManager()
# Alternative approach using thread locals (not recommended but sometimes necessary)
import threading
_thread_locals = threading.local()
def get_current_request():
return getattr(_thread_locals, 'request', None)
class RequestMiddleware:
def __call__(self, request):
_thread_locals.request = request
response = self.get_response(request)
if hasattr(_thread_locals, 'request'):
del _thread_locals.request
return response
# Now anywhere in your code:
request = get_current_request()
When to use multi-tenancy: I've built both types. Shared database with tenant_id column works for 90% of projects. It's simpler, cheaper, and easier to manage. True database-level isolation (separate schemas or databases) is only needed for compliance requirements (healthcare, finance) or when tenants have millions of records each.
Common Middleware Mistakes I've Made (So You Don't Have To)
- Forgetting to call get_response: Your middleware must call get_response(request) or the request never reaches your view. I've stared at blank pages for hours because of this.
- Modifying request in place without thinking: Adding attributes to request is fine, but modifying request.POST or request.GET can break other middleware.
- Not handling exceptions: If your middleware raises an exception, the entire request fails. Always wrap risky code in try-except.
- Returning None incorrectly: In process_request, returning None means "continue". Returning HttpResponse means "stop here". I've accidentally blocked all requests this way.
- Assuming request.user exists: Authentication middleware hasn't run yet if you put your middleware before it. Check the order!
Debugging Middleware Issues
# How to debug middleware problems
# 1. Add print statements (ugly but effective)
class DebugMiddleware:
def __call__(self, request):
print(f"1. Entering {self.__class__.__name__}")
response = self.get_response(request)
print(f"3. Exiting {self.__class__.__name__}")
return response
# 2. Use Django Debug Toolbar to see middleware execution
# It shows you which middleware ran and timing
# 3. Check middleware order in settings
from django.conf import settings
print(settings.MIDDLEWARE)
# 4. Temporarily disable middleware to isolate issues
MIDDLEWARE = [
# 'myapp.middlewares.ProblemMiddleware', # Comment out
'django.middleware.security.SecurityMiddleware',
# ...
]
# 5. Use response headers to debug
class DebugHeadersMiddleware:
def __call__(self, request):
response = self.get_response(request)
response['X-Debug-Path'] = request.path
response['X-Debug-Method'] = request.method
return response
Summary
Middleware is one of those Django features that seems mysterious until you need it. Then you realize how powerful it is. We covered:
- How middleware fits into the request-response cycle
- What each built-in middleware actually does (not just what the docs say)
- Building custom middleware for logging, IP blocking, and security headers
- Why order matters and how it affects performance
- Using middleware for multi-tenant applications
In the next chapter, we'll explore Django Signals - Django's event system that lets you run code when certain actions happen, like when a user saves a model or logs in.