Django signals
In-process publish/subscribe. A sender emits a signal, any number of receivers run synchronously, and the sender never learns who listened.
from django.db.models.signals import post_save
from django.dispatch import receiver
@receiver(post_save, sender=Order)
def notify(sender, instance, created, **kwargs):
if created:
audit_log.record("order.created", instance.pk)Note **kwargs — Django passes arguments you did not ask for, and a receiver
without it breaks the moment Django adds one.
The built-in ones worth knowing
| Signal | Fires | Watch for |
|---|---|---|
pre_save / post_save |
.save() |
created flag on post |
pre_delete / post_delete |
.delete() |
instance still has pk on pre |
m2m_changed |
related set edits | fires several times per change |
pre_migrate / post_migrate |
migrations | seeding default rows |
post_save gives you created: bool, which is how you tell an insert from an
update. pre_delete is where you read related data, because after the delete
the rows are gone.
The trap: bulk operations skip them entirely
# Neither of these fires save signals at all:
Order.objects.bulk_create(orders)
Order.objects.filter(paid=False).update(x=1)
# delete() does fire post_delete, per object.
Order.objects.filter(paid=False).delete()update() and bulk_create() go straight to SQL without instantiating and
saving each object. Any invariant you enforce in a signal is silently skipped.
This is the strongest practical argument against putting required logic in
a signal at all.
Signals fire inside the transaction
This is the bug that bites hardest:
@receiver(post_save, sender=Order)
def send_email(sender, instance, created, **kwargs):
if created:
mail.send(instance.customer.email) # WRONGpost_save runs before the surrounding transaction commits. If anything
later in the request raises, the order is rolled back — and the customer has
an email about an order that does not exist.
from django.db import transaction
@receiver(post_save, sender=Order)
def send_email(sender, instance, created, **kwargs):
if created:
transaction.on_commit(
lambda: mail.send(instance.customer.email)
)on_commit defers the callback until the outermost transaction commits, and
drops it entirely on rollback. Any side effect leaving the database — email,
webhook, queue message, cache invalidation — belongs inside it.
Registration: the other classic failure
A receiver that is never imported never runs. The signals module has to be imported at startup, and the sanctioned place is the app config:
class OrdersConfig(AppConfig):
name = "orders"
def ready(self):
from . import signals # noqa: F401Importing at module top level instead risks running before the app registry is populated. “My signal isn’t firing” is almost always this.
Gotcha:
ready()can run more than once under the autoreloader, and a receiver registered twice runs twice.@receiver(..., dispatch_uid="...")makes registration idempotent.
Why they get criticised
The cost is invisible control flow. Reading order.save() tells you nothing
about the six things that happen next, and grep does not help because there is
no call site. Debugging means knowing signals exist and going to look.
| Use a signal | Use an explicit call |
|---|---|
| Optional side effect | required business logic |
| Across apps you own | inside one service |
| Third-party model | your own model |
| Auditing, cache busting | anything with a return value |
The rule that holds up: if the operation is not allowed to fail silently, call it explicitly. A signal is for decoupling, and decoupling means you have accepted not knowing whether it ran.
Testing
from unittest.mock import patch
@patch("orders.signals.mail.send")
def test_no_email_on_rollback(mock_send):
with pytest.raises(ValueError):
with transaction.atomic():
Order.objects.create(...)
raise ValueError
mock_send.assert_not_called()Django’s TestCase wraps each test in a transaction that is rolled back, so
on_commit callbacks never fire by default. Use
django.test.TestCase.captureOnCommitCallbacks or TransactionTestCase when
that is what you are testing — otherwise a passing test proves nothing.
Related
Interview angle 5
- “When are signals appropriate?” - decoupling genuinely optional side effects, especially across apps you don’t control. For logic that must happen, an explicit call in a service is clearer and testable.
- “Why do signals get criticised?” - they make control flow invisible: a save triggers behaviour with no reference at the call site, which makes debugging and reasoning harder. They also fire inside the transaction, so side effects can occur for changes that later roll back.
- “How do you avoid the rollback problem?” -
transaction.on_commit()so the side effect runs only after a successful commit. Sending an email from apost_savesignal is the canonical bug this fixes. - “What silently skips signals?” -
bulk_create()andQuerySet.update()go straight to SQL without callingsave(), so nopre_saveorpost_savefires. Any invariant enforced only in a signal is bypassed, which is the strongest argument for keeping required logic out of them. - “My signal isn’t firing - why?” - the module was never imported. Register receivers in
AppConfig.ready(), and usedispatch_uidso the autoreloader importing it twice doesn’t run the receiver twice.