← Notes
Note · Django · 5 min read

Django signals: where side effects belong

When something happens in a Django app, other things usually need to follow: an audit entry, an owner assignment, a notification. Signals let one part of the code react to another without the two knowing about each other. Here's how I use them, and the traps to avoid.

A receiver in five lines

A receiver is a plain Python function that Django calls when something happens. Here, every new lead gets an audit entry, and the Lead model doesn't need to know that audit logs exist.

@receiver(post_save, sender=Lead)
def log_new_lead(sender, instance, created, **kwargs):
    if created:
        AuditLog.objects.create(action="lead_created", object_id=instance.pk)

Receivers only run if their module gets imported. The standard place to import it is the app's ready() method:

class LeadsConfig(AppConfig):
    name = "leads"

    def ready(self):
        from . import signals  # registers the receivers

Signals run inside the request

This is the part people forget. A receiver runs synchronously: same process, same request, same database transaction. If it takes two seconds, the user waits two seconds. If it raises an exception, the whole request fails.

So keep receivers small and fast: write a row, update a field, call a quick function. Anything slow, like a large report or a batch of API calls, doesn't belong in a signal.

Wait for the commit

post_save fires inside the transaction, before it commits. If the receiver talks to the outside world, such as sending an email or calling an API, and the transaction then rolls back, you've acted on a lead that doesn't exist.

transaction.on_commit delays the follow-up until the data is really saved:

@receiver(post_save, sender=Lead)
def lead_created(sender, instance, created, **kwargs):
    if created:
        transaction.on_commit(lambda: welcome_new_lead(instance))

If the transaction rolls back, the callback is simply dropped.

Don't save inside post_save

Calling instance.save() inside a post_save receiver fires post_save again, which calls save() again, and so on. Use a queryset update() instead. It writes straight to the database and sends no signals.

def welcome_new_lead(lead):
    owner = pick_owner(source=lead.source)
    Lead.objects.filter(pk=lead.pk).update(owner=owner)  # no second post_save
    Activity.objects.create(lead=lead, kind="created", note=f"From {lead.source}")

Writes that never send a signal

That same shortcut cuts both ways. QuerySet.update() and bulk_create() skip pre_save and post_save entirely, and so do raw SQL writes. If a rule must run on every change, a signal alone isn't enough: route the writes through one service function, or enforce the rule in the database.

WriteSends post_save?
obj.save(), Model.objects.create()Yes
QuerySet.update()No
bulk_create(), bulk_update()No
Raw SQLNo

Testing a receiver

Django's TestCase wraps each test in a transaction that never commits, so on_commit callbacks never fire on their own. captureOnCommitCallbacks runs them for you:

class LeadSignalTests(TestCase):
    def test_new_lead_gets_an_owner(self):
        with self.captureOnCommitCallbacks(execute=True):
            lead = Lead.objects.create(name="Camille", source="facebook")

        lead.refresh_from_db()
        self.assertIsNotNone(lead.owner)

How I choose

The work is…Put it in
Part of one specific actionThe view or a service function
Needed whenever a model is saved, from anywhereA signal
Talking to the outside world after a saveA signal + on_commit
Slow or heavyNot a signal: keep it out of the request

Used this way, signals keep apps independent without hiding surprises. It's the pattern behind the automatic follow-ups in the CRM and health platforms I work on.

Want to see it end to end? The Django request journey in the lab follows a webhook through validation, the save, the signal and its follow-up function.