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.
| Write | Sends post_save? |
|---|---|
obj.save(), Model.objects.create() | Yes |
QuerySet.update() | No |
bulk_create(), bulk_update() | No |
| Raw SQL | No |
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 action | The view or a service function |
| Needed whenever a model is saved, from anywhere | A signal |
| Talking to the outside world after a save | A signal + on_commit |
| Slow or heavy | Not 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.