Middleware & Signals

Django Intermediate/Advanced — Middleware & Signals
Django Intermediate/Advanced
Course 2 · Chapter 4 · Middleware & Signals

🧵 Middleware & Signals

Two Django features answer the same underlying question — "how do I run code without wiring it directly into every view that needs it?" — from opposite directions. Middleware wraps every request/response, project-wide, no matter which view handles it. Signals let code react to an event (a model saving, a request finishing) without the code that triggers the event ever knowing who's listening.

The Middleware Chain

MIDDLEWARE in settings.py (first seen back in Course 1's settings tour) is an ordered list — a request flows through it top-to-bottom, and the response flows back bottom-to-top, the same "onion" model Express's app.use() chain uses:

MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", # 1st in, last out "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", # sets request.user "django.contrib.messages.middleware.MessageMiddleware", # last in, 1st out ]

✍️ Writing Custom Middleware

The simplest form is a function that takes get_response (the next step in the chain) and returns a function that wraps it — request logic before calling it, response logic after:

# catalog/middleware.py import time import uuid def request_timing_middleware(get_response): # Runs ONCE, when the server starts — one-time configuration/setup. def middleware(request): # Runs on EVERY request, before the view. request.request_id = str(uuid.uuid4()) start = time.monotonic() response = get_response(request) # calls the next step in the chain (or the view itself) # Runs on EVERY response, after the view. duration_ms = (time.monotonic() - start) * 1000 print(f"[{request.request_id}] {request.path} took {duration_ms:.1f}ms") return response return middleware
# settings.py MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", # ... "catalog.middleware.request_timing_middleware", ]

request.request_id set here is exactly the correlation ID pattern from the TypeScript course's Logging & Observability chapter — attach one ID per request at the very top of the chain, and every view or log call downstream can reference it.

Express Middleware vs Django Middleware

Express
app.use((req, res, next) => { req.requestId = crypto.randomUUID(); const start = Date.now(); res.on("finish", () => { console.log(req.requestId, Date.now() - start); }); next(); // calls the next middleware });
Django
def request_timing_middleware(get_response): def middleware(request): request.request_id = str(uuid.uuid4()) start = time.monotonic() response = get_response(request) # calls the next middleware # ... log duration ... return response return middleware

Project-Wide, Not Per-View

Unlike @login_required (applied per-view), middleware in MIDDLEWARE runs on every single request the project handles — the right place for logging, timing, or security headers, not for view-specific access rules.

Class-Based Alternative

Middleware can also be a class with __init__(self, get_response) and __call__(self, request) — functionally identical, useful when a middleware needs to hold configuration set up once.

📡 Signals: Decoupled Event Notifications

A signal lets one piece of code announce "this happened" without knowing (or caring) who's listening — Django fires several built-in signals automatically, most usefully post_save and post_delete:

Auto-Creating a Profile When a User Registers

# catalog/models.py from django.db import models from django.contrib.auth.models import User class UserProfile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") bio = models.TextField(blank=True) # catalog/signals.py from django.db.models.signals import post_save from django.dispatch import receiver from django.contrib.auth.models import User from .models import UserProfile @receiver(post_save, sender=User) def create_user_profile(sender, instance, created, **kwargs): if created: UserProfile.objects.create(user=instance) # catalog/apps.py — signals.py must be imported somewhere, or it never runs from django.apps import AppConfig class CatalogConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "catalog" def ready(self): import catalog.signals # noqa

sender=User

Restricts the receiver to fire only for saves on the User model — without it, this function would run for every model's post_save, project-wide.

created

A boolean passed to every post_save receiver — True only on the initial INSERT, letting this receiver distinguish "a new user just registered" from "an existing user was updated."

💻 Coding Challenges

Challenge 1: Write a Security-Header Middleware

Write a middleware function that adds an X-Content-Type-Options: nosniff header to every response, then add it to MIDDLEWARE in the correct order relative to Django's own SecurityMiddleware.

Goal: Practice the get_response closure pattern for a response-modifying (rather than request-modifying) middleware.

→ Solution

Challenge 2: Write a post_delete Signal Receiver

Write a signal receiver that logs a message (e.g. print(f"Book deleted: {instance.title}")) whenever a Book is deleted, including the required apps.py wiring to make sure it actually runs.

Goal: Practice the full signal setup, including the easy-to-forget ready() import step.

→ Solution

Challenge 3: Middleware vs Signal — Which Fits?

For each of these three requirements, state whether middleware or a signal is the better fit, and why: (a) rejecting all requests from a blocked list of IP addresses, (b) sending a welcome email after a new user registers, (c) adding a Content-Security-Policy header to every response.

Goal: Practice recognizing which of the two tools actually matches a given requirement's shape.

→ Solution

⚠️ Gotcha: Signals Are "Spooky Action at a Distance"

A signal receiver connected in some other app's signals.py can silently run every time you call .save() on a model — with nothing at the call site hinting that anything else happens. This makes signals genuinely hard to trace when debugging: "why did a profile get created here?" sends you hunting across files instead of reading top-to-bottom. Reach for a signal only when the sender genuinely shouldn't know about the receiver (decoupled apps, plugin-style extensibility); for logic within your own app that always needs to happen together, a plain method call or an overridden save() is usually clearer. And remember: a signals.py file that's never imported from apps.py's ready() method never actually connects — the receivers simply don't run, with no error telling you why.

🎯 What's Next

Middleware and signals both run synchronously, inside the request/response cycle. The next chapter covers work that shouldn't block a response at all: Background Tasks — Celery basics and task queues for anything too slow to run while a user waits.