Back to Blog
Python

Python Django Middleware and Signals: When to Use Each

python django middleware and signals: Learn how Django middleware and signals differ, when to use each, and how to implement them correctly in real Django applications.

DjangoMiddlewareSignalsRequest LifecycleWeb Framework
Illustration showing a Django request passing through middleware layers while signals broadcast events to connected handlers.

Django exposes two extension mechanisms that developers often reach for when they need to run code at specific moments in an application's lifetime: middleware and signals. Python Django middleware and signals both let you hook into framework behavior, but they operate at different points in the request lifecycle and serve different purposes. Middleware participates directly in the request/response cycle, wrapping every request that enters the application. Signals broadcast application events to registered handlers, letting decoupled code react to model saves, request start, or login events. Understanding where each mechanism operates, and what that means for ordering, failure, and performance, is the difference between a clean implementation and one that breaks under load or becomes impossible to trace.

What Middleware Does in the Request Lifecycle

Middleware is a chain of classes that Django runs around each request. When a request arrives, Django passes it through the middleware list in the order defined in the MIDDLEWARE setting. Each middleware can modify the request before it reaches the view, short-circuit the request by returning a response directly, modify the response after the view produces it, or run code after the response is returned to the client.

The classic MiddlewareMixin-based class implements process_request and process_response. process_request runs before the view is called; process_response runs after the view returns a response, and it must return a response object. If process_response returns None, Django raises an error because the response chain expects a valid response at every step.

# middleware.py import time from django.utils.deprecation import MiddlewareMixin class RequestTimingMiddleware(MiddlewareMixin): def process_request(self, request): request._start_time = time.perf_counter() def process_response(self, request, response): start = getattr(request, "_start_time", None) if start is not None: elapsed = time.perf_counter() - start response["X-Request-Time"] = f"{elapsed:.4f}" return response

This middleware records the start time before the view runs and attaches the elapsed time as a response header afterward. The _start_time attribute is stored on the request object, which is the standard way to pass data between middleware hooks in the same request.

Middleware is registered in 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_project.middleware.RequestTimingMiddleware", ]

The order matters. process_request hooks run top to bottom, and process_response hooks run bottom to top. If your middleware depends on session or authentication data, it must be placed after the middleware that populates those objects.

What Signals Are and When They Fire

Signals are Django's publish-subscribe mechanism. A signal is an object that, when sent, calls every connected handler synchronously. Django ships with built-in signals for common events:

  • pre_save and post_save fire before and after a model's save() method
  • pre_delete and post_delete fire around delete()
  • request_started and request_finished fire when Django begins and ends processing a request
  • user_logged_in and user_logged_out fire on authentication changes

A handler is a plain function connected to a signal. When the signal is sent, Django calls each handler with the signal's arguments plus **kwargs.

# signals.py from django.contrib.auth.models import User from django.db.models.signals import post_save from django.dispatch import receiver @receiver(post_save, sender=User) def handle_new_user(sender, instance, created, **kwargs): if created: # Create a default profile, send a notification, etc. pass

The @receiver decorator connects the function to post_save for the User sender. The created flag distinguishes inserts from updates. The handler runs synchronously inside the same transaction as the model save, so anything it does is part of that transaction.

Connecting Signal Handlers Correctly

Signal handlers must be connected before the signal fires. The standard place is the ready() method of the app config. Importing the signals module at module level can cause circular imports or connect handlers before Django models are ready.

# apps.py from django.apps import AppConfig class AccountsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "accounts" def ready(self): import accounts.signals # noqa: F401

The import inside ready() registers the @receiver-decorated handlers. Django calls ready() for each app when the application registry is fully populated, which is the earliest safe point to connect handlers that reference models.

If you connect a handler manually with Signal.connect(), you must ensure the connection happens exactly once. Importing the same module multiple times, or calling connect() in a location that runs more than once, will register duplicate handlers and run the same code multiple times per event.

Middleware vs Signals: Choosing the Right Mechanism

The decision between middleware and signals comes down to what you are reacting to.

CriterionMiddlewareSignals
Hook pointRequest/response cycleApplication events
Runs onEvery requestOnly when the signal is sent
Access to requestFull request objectDepends on the signal
Typical useLogging, headers, auth, cachingModel events, post-save actions
Failure effectException propagates through chainException propagates to sender

Use middleware when your logic must run for every request, or when you need to inspect or modify the request before it reaches the view. Use signals when you want to react to an event that happens inside the application, especially model lifecycle events, without coupling the code that triggers the event to the code that handles it.

A common mistake is using signals to perform work that is really request-scoped. If you need to add a header to every response, that is middleware work. If you need to create a profile row whenever a user is created, that is signal work.

Ordering and Failure Behavior

Middleware ordering is explicit and visible in MIDDLEWARE. Signal ordering is not. Handlers run in the order they were connected, but Django does not guarantee a stable order across app loading, and there is no built-in priority system. If two handlers depend on each other's effects, you cannot rely on registration order; you should make the handlers independent or trigger the dependent work explicitly.

Failure behavior also differs. An exception raised in process_request stops the middleware chain and propagates to Django's exception handling. An exception raised in a signal handler propagates to the code that sent the signal, which means a model save that triggers a failing handler will fail the save. This is often surprising: a post_save handler that raises an exception will roll back the transaction, even if the save itself was valid.

Performance and Maintainability Concerns

Signals are synchronous. A slow handler blocks the request thread. If a post_save handler sends an email or calls an external API, the request waits for it. Offloading that work to a background task queue is the standard fix, but the signal handler still needs to enqueue the task, and enqueueing has its own latency.

Middleware runs on every request, so the cost of each middleware is multiplied by request volume. A middleware that performs a database query on every request can become a bottleneck. Keep middleware work cheap, and move expensive work behind caching or background processing.

Maintainability is the other concern. Signal handlers are registered implicitly through imports, so a developer reading a view that saves a model cannot see that a signal handler will run afterward. Middleware is visible in MIDDLEWARE, but the chain can still be long. Both mechanisms benefit from keeping handlers small and named clearly.

Common Pitfalls with Middleware and Signals

One recurring problem is forgetting to return the response from process_response. Django expects a response object back; returning None raises an error. Another is assuming process_request runs for every request type. Django skips middleware for some internal requests, and the behavior of process_view differs from process_request in when it runs relative to URL resolution.

On the signals side, the most common pitfall is connecting handlers at module import time rather than in ready(). This can cause AppRegistryNotReady errors when the handler references models before the registry is populated. Another is assuming signals are asynchronous. They are not; every handler runs synchronously in the calling thread.

A subtler issue is signal handlers that mutate the instance passed to them. A pre_save handler that changes instance.field will affect the save, which may be intentional or may be a hidden dependency. Document that behavior explicitly if you rely on it.

python django middleware and signals: Practical Usage and Co | RYUSLOG DEV