Django signals

Updated 5 interview angles 4 min read source
On this page6
  1. The built-in ones worth knowing
  2. Signals fire inside the transaction
  3. Registration: the other classic failure
  4. Why they get criticised
  5. Related
  6. Interview angle

Django signals

In-process publish/subscribe. A sender emits a signal, any number of receivers run synchronously, and the sender never learns who listened.

python
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

python
# 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:

python
@receiver(post_save, sender=Order)
def send_email(sender, instance, created, **kwargs):
    if created:
        mail.send(instance.customer.email)   # WRONG

post_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.

python
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:

python
class OrdersConfig(AppConfig):
    name = "orders"

    def ready(self):
        from . import signals      # noqa: F401

Importing 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

python
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.

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 a post_save signal is the canonical bug this fixes.
  • “What silently skips signals?” - bulk_create() and QuerySet.update() go straight to SQL without calling save(), so no pre_save or post_save fires. 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 use dispatch_uid so the autoreloader importing it twice doesn’t run the receiver twice.