Django Advanced

Django Signals and Custom Middleware: Production Patterns

Advanced patterns for signals and middleware. Learn when signals cause more pain than value, how to write thread-safe middleware, audit logging, request tracing, and debugging production issues.

DjangoZen Team Apr 17, 2026 19 min read 166 views

Signals and middleware are two of Django's most powerful extension points — and two of the most misused. Signals let code react to events without tight coupling; middleware lets you process every request and response in one place. Used well, they keep cross-cutting concerns clean and decoupled. Used badly, they create invisible action-at-a-distance that makes a codebase impossible to reason about. This tutorial covers both: how they work, the patterns that make them valuable, and the crucial judgment of when not to use them.

What signals are

Django signals implement the observer pattern: a sender emits a signal when something happens, and any number of receivers registered for that signal run in response, without the sender knowing who is listening. Django ships built-in signals for common events — a model being saved or deleted, a request starting or finishing, a user logging in — and you can define your own. The appeal is decoupling: code that needs to react to an event does so by listening, rather than the code that causes the event having to call it directly. This lets you add reactions to events without modifying the code that triggers them, which is the core value signals provide.

Model lifecycle signals

The most-used signals are the model lifecycle ones — pre_save, post_save, pre_delete, post_delete — which fire around saving and deleting model instances:

from django.db.models.signals import post_save
from django.dispatch import receiver

@receiver(post_save, sender=User)
def create_profile(sender, instance, created, **kwargs):
    if created:
        Profile.objects.create(user=instance)

This creates a profile automatically whenever a new user is created, without the user-creation code knowing about profiles. The created flag distinguishes a new object from an update — a detail people often forget, causing logic to run on every save instead of only on creation. Model signals are where most signal usage lives, and most signal mistakes too.

Connecting receivers properly

Receivers must be registered when the app starts, and the correct place is the app config's ready method, which Django calls once on startup. Importing signal modules there ensures receivers are connected exactly once. A common bug is connecting receivers in the wrong place — a module that gets imported multiple times — which registers the receiver more than once and makes it fire repeatedly for a single event. Understanding that receivers are global registrations that must happen once, in ready, avoids the duplicate-execution bugs that confuse people new to signals. Where and how you connect a receiver is as important as what it does.

The danger of signals

Signals' great strength — decoupling the event from the reaction — is also their great danger. Because a signal receiver runs without any visible call from the code that triggered it, signals create action at a distance: saving a model can trigger a cascade of effects scattered across the codebase, invisible at the save site. This makes behavior hard to trace ("why did saving this object send an email?"), hard to debug, and hard to reason about. The very invisibility that makes signals convenient is what makes them dangerous at scale, turning a straightforward operation into one with unpredictable, hidden consequences. Respecting this danger is the key to using signals well rather than regretting them.

When to use signals — and when not to

The judgment that separates good Django code from a tangled mess is knowing when a signal is appropriate. Signals fit genuinely decoupled, cross-cutting reactions — especially when the reacting code lives in a different app that the triggering code should not depend on, or when you cannot modify the triggering code. They are the wrong tool when the reaction is core to the operation and belongs in plain sight: if saving an order should always create an invoice, calling that logic explicitly in the save flow is clearer and more maintainable than hiding it in a signal. The rule of thumb: prefer an explicit call when you can, and reach for a signal only when decoupling genuinely justifies the loss of visibility.

Alternatives to signals

Often the cleaner choice is not a signal at all. Overriding a model's save method, or putting logic in a form or a service function, keeps the behavior explicit and traceable in a way signals do not. For the very common "create a related object when this is created" case, doing it in an overridden save or a service function makes the relationship visible at the point it happens. Many codebases overuse signals for logic that would be clearer as a direct call, accumulating hidden behavior that later maintainers struggle to untangle. Before reaching for a signal, ask whether an explicit method or service would do the job more legibly — frequently it would, and the code is better for it.

Signals and side effects

A particular trap is doing slow or unreliable work inside a signal receiver — sending an email, calling an external API — synchronously. Because the receiver runs as part of the triggering operation (often inside the request), a slow signal handler slows the request, and a failing one can break the save. The right pattern is to keep signal receivers fast and offload heavy or unreliable work to a background task: the receiver enqueues a Celery task rather than doing the work inline. This keeps the triggering operation fast and resilient, and isolates the side effect's failures from the core operation. A signal that does slow work synchronously couples the reliability of an unrelated side effect to your main flow, which is exactly what you do not want.

What middleware is

Middleware is a layer that wraps the processing of every request and response, letting you run code before the view sees a request and after the view produces a response, in one central place. Each middleware is a layer in an onion: a request passes inward through each middleware to the view, and the response passes back outward through each in reverse. This makes middleware the natural home for concerns that apply to every request — authentication, security headers, logging, request timing — that you do not want to repeat in every view. Understanding the onion model, where order matters and each layer can act on the way in and the way out, is the foundation of using middleware correctly.

Writing middleware

Modern Django middleware is a callable that takes the next handler and returns a function processing the request:

class TimingMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        start = time.monotonic()
        response = self.get_response(request)      # call inward to the view
        response["X-Duration"] = str(time.monotonic() - start)
        return response

Code before get_response runs on the way in; code after runs on the way out. This structure — do something with the request, pass it along, then do something with the response — is the shape of nearly all middleware, and it is where request-wide behavior naturally lives.

Middleware order matters

Middleware runs in the order it is listed, wrapping like nested layers, and that order has real consequences. Security middleware must run early to protect everything inside it; middleware that depends on the authenticated user must run after authentication middleware has identified them. Getting the order wrong leads to subtle bugs — a middleware that needs the user running before the user is set, or a security check running after the code it was meant to protect. Because each middleware wraps those listed after it, the sequence in your settings is not arbitrary: it defines what each layer can see and protect. Reasoning about order is an essential part of working with middleware correctly.

Good uses for middleware

Middleware shines for genuine cross-cutting concerns that apply uniformly across requests: adding security headers to every response, logging or timing all requests, enforcing authentication or rate limits globally, attaching request-scoped context like a correlation ID, or handling exceptions consistently. These are concerns that would be tedious and error-prone to repeat in every view, and that belong in one central, ordered place. When a behavior genuinely applies to all (or most) requests and is orthogonal to any particular view's logic, middleware is exactly the right tool, keeping the cross-cutting concern in one maintainable location rather than scattered through the codebase.

When middleware is the wrong tool

Middleware is misused when it carries logic specific to particular views or routes, because it then runs on every request only to do nothing for most of them, adding overhead and obscuring where behavior actually lives. View-specific logic belongs in the view, a decorator, or a mixin — not in a global layer that pretends to be universal but really only matters for a few URLs. Like signals, middleware can become a place where hidden behavior accumulates, making requests do things that are hard to trace. Reserve middleware for the genuinely universal, and keep view-specific concerns close to the views they belong to, so the global layer stays small and its purpose stays clear.

Performance and middleware

Because every middleware runs on every request, anything slow in a middleware slows your entire application uniformly. A database query or an external call in a middleware multiplies across all traffic, so middleware code must be lean. This is a meaningful constraint: a piece of work that is fine in one view becomes a serious cost when placed in middleware and executed on every request. Keep middleware fast, avoid per-request database queries where possible, and be conscious that the convenience of "run this on everything" carries the cost of "this runs on everything." The universality that makes middleware powerful also means its performance cost is paid on every single request your app serves.

Testing signals and middleware

Both extension points need testing, and their global nature makes it important. For signals, test that the receiver does what it should when the signal fires, and be aware that signals firing during unrelated tests can cause surprising interactions — sometimes you disconnect a signal during a test to isolate behavior. For middleware, test that it correctly processes requests and responses, including the edge cases. Because signals and middleware run implicitly rather than through explicit calls, their effects can be easy to overlook in testing, which is part of why they can hide bugs. Deliberately covering them with tests ensures their invisible behavior is verified rather than assumed.

Signals and transaction boundaries

A subtle trap with model signals is their relationship to database transactions. A post_save signal fires when the save happens, but if that save is inside a transaction that later rolls back, any side effect the signal already triggered — an email sent, an external call made — cannot be undone. For side effects that must only happen if the transaction commits, Django provides transaction.on_commit, which defers an action until the surrounding transaction successfully commits. Understanding that a signal can fire for a database change that ultimately rolls back is important for any signal with external side effects, and on_commit is the tool for tying those effects to actual commitment.

Async middleware and modern Django

As Django gains async capabilities, middleware can be written to support asynchronous request handling, and understanding how your middleware interacts with sync and async views matters for performance. Middleware that is not async-aware can force async views to run in a way that loses their benefit. For most applications the synchronous middleware path is fine, but if you adopt async views for their concurrency, ensuring your middleware stack supports async correctly is necessary to actually realize that benefit. Being aware of the sync/async nature of your middleware, and matching it to how your views run, prevents the subtle performance loss that comes from a mismatch between an async view and a synchronous middleware chain wrapping it.

App configuration and the registry

The broader context for connecting signals is Django's application registry and the app-loading process. Each app has a configuration class whose ready method runs once after the app registry is fully populated, which is precisely why it is the correct place to import and connect signal receivers — everything is loaded and connecting there happens exactly once. Understanding this lifecycle clears up a lot of confusion about why receivers belong in ready and not at module top level in arbitrary files. The app config is also where other one-time startup wiring belongs, making it a useful piece of Django architecture to understand beyond just signals — the single, well-defined place for initialization that must happen once per process.

Summary

Signals and middleware are powerful Django extension points whose value depends entirely on disciplined use. Signals decouple a reaction from the event that triggers it, which is ideal for genuinely cross-cutting concerns and cross-app reactions you cannot wire directly — but the same decoupling creates invisible action at a distance, so prefer an explicit method or service call when you can, connect receivers once in the app's ready, mind the created flag, and offload slow work to background tasks rather than running it inline. Middleware wraps every request and response in an ordered onion, making it the right home for universal concerns like security headers, logging, authentication, and request context — but order matters, view-specific logic does not belong there, and because it runs on every request it must stay lean. The thread through both is the same: these tools trade visibility for power, so use them where decoupling or universality genuinely justifies the cost, and keep everything else explicit and close to where it belongs.