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.
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.
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.
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.
Every receiver is called with keyword arguments, and post_save sends more than just instance and created. The raw flag is True when the object is being loaded from a fixture with loaddata; in that case related rows may not exist yet, so receivers that touch other tables should return early. The update_fields argument is the set of field names passed to save(update_fields=...), or None for a full save, which lets a receiver skip work when only an irrelevant field changed. Always accept **kwargs: Django reserves the right to add arguments to built-in signals, and a receiver with a closed signature breaks when that happens.
@receiver(post_save, sender=Order)
def reindex_order(sender, instance, created, raw, update_fields, **kwargs):
if raw:
return # loading fixtures: related data may be missing
if update_fields is not None and "status" not in update_fields:
return # a save that did not touch status is irrelevant here
search_index.schedule(instance.pk)
Model signals are sent by Model.save() and Model.delete() and by the deletion collector, not by the database. Several common ORM operations bypass them entirely, which is the single most frequent source of "my receiver did not run" reports:
| Operation | Signals sent | Consequence |
|---|---|---|
QuerySet.update() | None | Receivers keyed on post_save silently miss bulk status changes |
bulk_create() / bulk_update() | None | Profiles, audit rows or cache invalidation are skipped for imported data |
QuerySet.delete() | pre_delete/post_delete per object | Django must fetch every row to send them, so bulk deletes get slower once receivers exist |
Cascade deletes via on_delete=CASCADE | Delete signals for each cascaded object | A single delete can fan out into thousands of receiver calls |
| Raw SQL, data migrations using raw SQL, other services writing to the table | None | Anything outside the ORM's save/delete path is invisible to signals |
If a reaction must happen for every change to a row regardless of how it was written, a signal is the wrong layer; that is a job for a database trigger or for routing all writes through a single service function.
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.
ready()The standard layout keeps receivers in a signals.py module and imports it from the app config. The import itself performs the registration, because the @receiver decorator connects the function at import time:
# accounts/apps.py
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "accounts"
def ready(self):
from . import signals # noqa: F401 (the import registers the receivers)
If you connect receivers manually with post_save.connect(...), pass a dispatch_uid string. Django uses it as the registration key, so a second connect with the same uid is ignored instead of creating a duplicate receiver. It is cheap insurance against modules that end up imported twice under different paths. Also remember that for model signals sender can be given as a lazy "app_label.ModelName" string, which avoids importing models from another app at module load time and the circular imports that often follow.
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.
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.
Built-in model signals describe database mechanics ("a row was saved"), which is rarely the event other code really cares about. A custom signal lets you publish a domain event such as "an order was paid", with exactly the arguments listeners need, sent from the one place where that event genuinely happens:
# billing/signals.py
from django.dispatch import Signal
# Sent after an order's payment has been captured.
# Keyword arguments: order, amount, currency
order_paid = Signal()
# billing/services.py
from .signals import order_paid
def capture_payment(order, amount, currency):
... # talk to the payment provider, update the order
order_paid.send_robust(sender=order.__class__, order=order,
amount=amount, currency=currency)
A domain signal is far easier to reason about than post_save with a pile of "did the status just change to paid?" checks in every receiver. Document the arguments in a comment next to the definition, because Signal() no longer accepts a providing_args parameter and nothing else tells readers what to expect.
send versus send_robustsend() calls receivers in registration order and lets the first exception propagate, which means later receivers never run and the sender sees the error. send_robust() catches exceptions derived from Exception, logs them, and returns them in the list of (receiver, response) pairs, so one broken listener cannot stop the others. For a domain event with several independent listeners in different apps, send_robust is usually the right default; for a signal where a failing receiver should abort the operation, use send deliberately. Since Django 5.0, asend() and asend_robust() are available for sending from async code; sync receivers are wrapped with sync_to_async automatically, and receivers can themselves be coroutine functions.
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.
Here is the "create the related object" case written as a service function instead of a receiver. A reader of the signup view sees exactly what happens, and a test can call it directly:
# accounts/services.py
from django.db import transaction
from .models import Profile, User
from .tasks import send_welcome_email
@transaction.atomic
def register_user(*, email, password, display_name):
user = User.objects.create_user(email=email, password=password)
Profile.objects.create(user=user, display_name=display_name)
transaction.on_commit(lambda: send_welcome_email.delay(user.pk))
return user
The atomic block also fixes a latent bug in the signal version: if profile creation fails, the user row is rolled back rather than left behind without a profile.
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.
A robust receiver that hands off to Celery looks like this. It passes only the primary key, never the instance, because the task runs in another process and must read the committed state from the database itself:
from django.db import transaction
from django.db.models.signals import post_save
from django.dispatch import receiver
from .tasks import sync_customer_to_crm
@receiver(post_save, sender="billing.Customer", dispatch_uid="crm_sync")
def queue_crm_sync(sender, instance, created, raw, **kwargs):
if raw:
return
pk = instance.pk
transaction.on_commit(lambda: sync_customer_to_crm.delay(pk))
Model signals are sent with send(), so an exception in a receiver propagates out of save(). Note the timing for post_save: Django sends it after the row has been written. In autocommit mode, without an enclosing atomic block, the row is already committed when the receiver raises, so the caller gets an exception and a 500 page even though the data was saved. With ATOMIC_REQUESTS or an explicit atomic block, the same exception rolls the write back. Neither outcome is what most authors intended when they added a "harmless" notification receiver, which is another reason to keep receivers tiny and push fallible work into a task.
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.
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.
Besides __call__, a middleware class may define optional hook methods that Django calls at specific points:
| Hook | Called | Typical use |
|---|---|---|
process_view(request, view_func, view_args, view_kwargs) | After URL resolution, just before the view | Decisions that depend on which view will run, e.g. checking an attribute set by a decorator |
process_exception(request, exception) | When the view raises | Converting domain exceptions to responses, reporting to an error tracker; return None to let default handling continue |
process_template_response(request, response) | When the view returns a TemplateResponse, before rendering | Adjusting template context or template name globally |
If a middleware decides at startup that it has nothing to do, for example because a feature flag in settings is off, it can raise django.core.exceptions.MiddlewareNotUsed from __init__. Django then drops it from the chain entirely, which costs nothing per request, unlike a middleware that checks the flag on every call.
Code after get_response sees whatever the inner layers returned, which is not always a plain HttpResponse. A StreamingHttpResponse or FileResponse has no content attribute; middleware that rewrites bodies must check response.streaming first and leave streamed responses alone. Setting headers is safe on both kinds, which is why header-only middleware such as the timing example above is the low-risk pattern.
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.
Django's default settings illustrate the reasoning. Annotated, the stock list reads like a dependency chain:
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", # HTTPS redirect, HSTS: outermost
"django.contrib.sessions.middleware.SessionMiddleware", # sets request.session
"django.middleware.common.CommonMiddleware", # APPEND_SLASH handling
"django.middleware.csrf.CsrfViewMiddleware", # needs sessions if CSRF_USE_SESSIONS
"django.contrib.auth.middleware.AuthenticationMiddleware", # needs request.session
"django.contrib.messages.middleware.MessageMiddleware", # needs sessions or cookies
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"myproject.middleware.RequestIDMiddleware", # custom: after auth, can log the user
]
A useful habit is to write the dependency next to each custom entry in a comment, as above. When someone later reorders the list, the comment tells them what will break.
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.
Correlation IDs are the textbook middleware job: every request gets an identifier, every log line emitted while handling it carries that identifier, and the response returns it so a user-reported error can be matched to server logs. Using a contextvars.ContextVar rather than a thread-local makes it work under both WSGI and ASGI, because asgiref propagates context into the threads it uses to run sync code.
# myproject/middleware.py
import contextvars
import logging
import re
import uuid
request_id_var = contextvars.ContextVar("request_id", default="-")
_VALID = re.compile(r"^[A-Za-z0-9\-]{8,64}$")
class RequestIDMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
incoming = request.headers.get("X-Request-ID", "")
rid = incoming if _VALID.match(incoming) else uuid.uuid4().hex
token = request_id_var.set(rid)
try:
response = self.get_response(request)
finally:
request_id_var.reset(token)
response["X-Request-ID"] = rid
return response
class RequestIDFilter(logging.Filter):
def filter(self, record):
record.request_id = request_id_var.get()
return True
Register the filter on your log handlers and add %(request_id)s to the formatter. Two details matter in production. First, an incoming ID from a trusted load balancer is reused so a trace spans proxy and application, but it is validated against a strict pattern, because a client-supplied header written into logs is a log-injection vector. Second, the reset in finally guarantees the value does not leak into the next request handled by the same worker even when the view raises. Pass the ID into Celery tasks as an argument if you want the correlation to continue into background work.
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.
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.
request.userAuthenticationMiddleware sets request.user as a lazy object; the session lookup and user query only run when something touches it. A custom middleware that reads request.user.is_authenticated on every request, including static asset and health-check paths, forces those queries on requests that would otherwise never hit the database. Check the cheap conditions first (path prefix, method) and touch the user last. The same applies to request.session: reading it loads the session, and modifying it causes a session write and a Set-Cookie header on the response.
A quick way to measure the cost of your stack is to request a trivial view in a test with django.test.utils.CaptureQueriesContext active, or to watch the SQL panel in Django Debug Toolbar: if a view that does no database work shows queries, middleware is the likely source.
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.
In practice, tests should assert the observable effect of a receiver, and middleware can be tested without spinning up the whole stack. RequestFactory builds a request and you pass a stub get_response:
from unittest import mock
from django.http import HttpResponse
from django.test import RequestFactory, SimpleTestCase, TestCase
from billing.models import Customer
from myproject.middleware import RequestIDMiddleware
class RequestIDMiddlewareTests(SimpleTestCase):
def test_generates_id_when_header_invalid(self):
request = RequestFactory().get("/", HTTP_X_REQUEST_ID="bad id!")
response = RequestIDMiddleware(lambda r: HttpResponse("ok"))(request)
self.assertRegex(response["X-Request-ID"], r"^[0-9a-f]{32}$")
class CustomerSignalTests(TestCase):
@mock.patch("billing.signals.sync_customer_to_crm.delay")
def test_sync_queued_only_after_commit(self, delay):
with self.captureOnCommitCallbacks(execute=True) as callbacks:
customer = Customer.objects.create(name="Acme")
self.assertEqual(len(callbacks), 1)
delay.assert_called_once_with(customer.pk)
TestCase wraps each test in a transaction that never commits, so on_commit callbacks would never run; captureOnCommitCallbacks(execute=True) collects them and runs them when the block exits, letting you assert both that the task was deferred and what it was called with. For tests that need a receiver out of the way, prefer patching the side-effect function over disconnecting the signal globally, because a disconnect that is not restored in a finally leaks into every later test.
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.
When several on_commit callbacks are registered and one of them might fail, pass robust=True (available since Django 4.2): the exception is logged and the remaining callbacks still run, instead of the first failure preventing the rest. Also note that on_commit outside any transaction, in autocommit mode, runs the callback immediately, so the same receiver behaves correctly both inside and outside an atomic block.
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.
Concretely: in an ASGI deployment, Django adapts each middleware to the mode of the handler it wraps. A sync-only middleware in front of an async view forces a switch into a thread and back for every request, which costs latency and gives up the concurrency the async view was meant to provide. A middleware becomes async-capable by declaring it and marking itself as a coroutine function when the inner handler is async:
import time
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
class TimingMiddleware:
sync_capable = True
async_capable = True
def __init__(self, get_response):
self.get_response = get_response
self.is_async = iscoroutinefunction(get_response)
if self.is_async:
markcoroutinefunction(self)
def __call__(self, request):
if self.is_async:
return self._acall(request)
start = time.monotonic()
response = self.get_response(request)
response["X-Duration"] = f"{time.monotonic() - start:.4f}"
return response
async def _acall(self, request):
start = time.monotonic()
response = await self.get_response(request)
response["X-Duration"] = f"{time.monotonic() - start:.4f}"
return response
Every middleware in the stack has to be async-capable for a request to stay on the event loop end to end, including third-party ones, so audit the whole list before concluding that async views "do not help". Setting the django.request logger to DEBUG makes Django log when it adapts a middleware between sync and async, which is the fastest way to find the layer causing the switch.
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.
An audit log is a case where signals and middleware legitimately cooperate. The requirement: every change to selected models records who made it and from which request, without every view having to remember. Middleware supplies the request context, a receiver records the change, and on_commit ensures only committed changes are logged.
# audit/context.py
import contextvars
current_request = contextvars.ContextVar("current_request", default=None)
# audit/middleware.py (listed after AuthenticationMiddleware)
from .context import current_request
class AuditContextMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
token = current_request.set(request) # resolve the user lazily, only if needed
try:
return self.get_response(request)
finally:
current_request.reset(token)
# audit/signals.py
from django.db import transaction
from django.db.models.signals import post_delete, post_save
from myproject.middleware import request_id_var
from .context import current_request
from .models import AuditEntry
AUDITED = ("billing.Invoice", "billing.Customer")
def _record(instance, action):
request = current_request.get()
user = getattr(request, "user", None)
entry = AuditEntry(
model=instance._meta.label,
object_pk=str(instance.pk),
action=action,
actor_id=user.pk if user is not None and user.is_authenticated else None,
request_id=request_id_var.get(),
)
transaction.on_commit(entry.save)
def on_save(sender, instance, created, raw, **kwargs):
if not raw:
_record(instance, "create" if created else "update")
def on_delete(sender, instance, **kwargs):
_record(instance, "delete")
def connect():
for label in AUDITED:
post_save.connect(on_save, sender=label, dispatch_uid=f"audit-save-{label}")
post_delete.connect(on_delete, sender=label, dispatch_uid=f"audit-del-{label}")
The audit app's ready() calls connect(). Several choices here are deliberate. The middleware stores the request rather than the user, so requests that never touch an audited model never trigger the user query. Changes made outside a request, in management commands or Celery tasks, get a None actor instead of crashing, and those entry points can set the context variable themselves if they want a system identity recorded. The entry is saved via on_commit, so a rolled-back transaction leaves no phantom audit rows. And the list of audited models lives in one place, which answers the "why does saving this write a row elsewhere?" question for the next maintainer. The limits from earlier still apply: QuerySet.update() and bulk_create() bypass this, so for regulatory-grade auditing combine it with a rule that audited models are only written through service functions, or use database triggers.
| Symptom | Likely cause | Fix |
|---|---|---|
| Receiver never runs | Signals module never imported; write went through update() or bulk_create() | Import it in ready() and confirm the app is in INSTALLED_APPS; route the write through save() or a service |
| Receiver runs twice per save | Connected from two import paths or from code that runs repeatedly | Add dispatch_uid; connect only in ready() |
| Task reads stale or missing data | Task queued before the transaction committed | Queue inside transaction.on_commit; pass primary keys |
| Email sent for a rolled-back change | Side effect executed directly in the receiver | Defer it with on_commit |
loaddata fails with integrity errors | Receiver creates related rows while fixtures load | Return early when raw is true |
Attribute missing on request inside middleware | Custom middleware listed before the one that sets it | Move it after its dependency; document the dependency inline |
| Every request runs database queries | Middleware touching request.user or the session unconditionally | Short-circuit on cheap checks first |
| Async views no faster than sync | A sync-only middleware in the stack | Make it async-capable or replace it |
For "what runs when I save this model?", the Signals panel in Django Debug Toolbar lists connected receivers, and in a shell post_save.has_listeners(Invoice) tells you whether anything is listening at all. The signal's receivers attribute can be inspected for more detail, but it is internal and its structure is not a stable API, so use it for debugging only.
Many mature codebases carry dozens of post_save receivers accumulated over years. Removing them in one sweep is risky because nobody is sure which behavior depends on which receiver. An incremental path works better:
@receiver and .connect(, and for each receiver record the sender, the side effect and whether it is transaction-safe. This list alone often reveals duplicates and dead receivers for models that no longer exist.post_save.The payoff shows up in onboarding and incident response: the full consequences of "create an order" become readable in one function, and the remaining signals are the few that truly earn their invisibility.
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.