Django signals let one part of an app react to something that happened in another part, without the two knowing about each other. They are genuinely useful, and they are also one of the easiest ways to make a codebase hard to follow. Both things are true at once.
What a signal is
A signal is the observer pattern. A sender announces that something
occurred, and any number of receivers run in response. Django ships
signals for the common model events: post_save,
pre_delete, m2m_changed, and more.
A receiver
from django.db.models.signals import post_save
from django.dispatch import receiver
@receiver(post_save, sender=Order)
def notify_on_new_order(sender, instance, created, **kwargs):
if created:
send_order_confirmation.delay(instance.pk)
Where receivers should live
Put them in a signals.py module and connect it from the
app config, so the receivers register exactly once when the app loads.
from django.apps import AppConfig
class OrdersConfig(AppConfig):
name = "orders"
def ready(self):
from . import signals # noqa: F401
When signals help
Signals shine for cross-cutting concerns that are not part of the core operation: invalidating a cache, writing an audit record, syncing a search index. The order does not care that a search index exists, and keeping that wiring out of the order code is the right call.
When signals hurt
If a side effect is essential to the operation, call it explicitly. Someone reading a view should see what it does without having to grep the project for receivers. Hidden control flow is the real cost of signals, and it is a steep one.
The save-inside-a-signal trap
A post_save receiver that calls save() on the
same instance triggers itself, again and again. Update the row with
queryset.update(), or pass update_fields and
guard on it, so the receiver does not recurse.
Signals skip bulk operations
This catches people out. bulk_create,
bulk_update, and queryset.update() do not fire
per-row signals. If a cache or index relies on a signal to stay fresh,
a bulk write will silently leave it stale. Know which path your code
takes.
The rule I follow
Signals for cross-cutting concerns that the core code should not know about. Plain function calls for anything the operation actually depends on. Used that way, signals stay an asset instead of becoming a mystery.