Skip to content
Codeloom
Django

Writing Custom Middleware in Django

Build custom Django middleware — request/response processing, exception handling, middleware ordering, and real-world examples.

·6 min read · By Codeloom
Intermediate 11 min read

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:

  1. SecurityMiddleware — first, to enforce HTTPS and HSTS
  2. SessionMiddleware — before anything that uses request.session
  3. AuthenticationMiddleware — after sessions, before any auth checks
  4. 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.