Django

Django signals — what they're for, and where they bite

Decoupling side effects from the code that triggers them, and the specific gap between save() and bulk operations that catches almost everyone once.

Intermediate

3 min read

What a signal actually is

A signal lets code in one part of an app react to an event happening somewhere else, without the code that triggers the event needing to know anything about the code that reacts to it:

from django.db.models.signals import post_save
from django.dispatch import receiver
 
@receiver(post_save, sender=Order)
def send_order_confirmation(sender, instance, created, **kwargs):
    if created:
        send_confirmation_email(instance)

Order.objects.create(...) (or .save() on a new instance) triggers post_save, which calls every connected receiver — here, sending a confirmation email. The Order model itself, and any view that creates an Order, has no idea this email logic exists. That's the actual value: decoupling — email sending can be added, changed, or removed without touching model or view code at all.

The common signals, briefly

  • pre_save / post_save — fires around .save(). post_save's created argument tells you whether this was an insert or an update.
  • pre_delete / post_delete — fires around .delete().
  • m2m_changed — fires when a ManyToManyField relationship changes (.add(), .remove(), .clear(), .set()).
  • request_started / request_finished — fire around the request/response cycle itself, useful for cross-cutting concerns unrelated to any specific model.

Where this breaks: bulk operations skip signals entirely

Order.objects.bulk_create([Order(customer=c1, total=50), Order(customer=c2, total=75)])
# post_save never fires for either of these — no confirmation emails sent

bulk_create() builds one INSERT statement (or a small number of batched ones) for efficiency — it never calls .save() on each instance individually, and signals are dispatched from inside .save()/.delete(), not by the raw database operation itself. The same gap applies to QuerySet.update() and QuerySet.delete() — both operate directly in SQL for every matching row, without instantiating or saving each object, so pre_save/post_save/pre_delete/post_delete never fire for any of them.

This is a genuinely common production surprise: everything works correctly in normal usage (creating orders one at a time through a view), and then a batch-import script using bulk_create for performance silently skips every side effect that was wired up through signals — no error, no warning, just missing emails, missing search-index updates, missing cache invalidation.

The actual fix

There isn't a signal-based fix — the fix is recognizing that anything genuinely required to happen (not just nice-to-have) shouldn't rely on .save() being called at all. Two real approaches:

# Explicit: call the side effect directly in the same code path as the bulk operation
orders = Order.objects.bulk_create([...])
for order in orders:
    send_confirmation_email(order)
 
# Or: move the required logic into an explicit service function that both
# the normal create path AND any bulk path are required to call
def create_order(customer, total):
    order = Order.objects.create(customer=customer, total=total)
    send_confirmation_email(order)
    return order

The second pattern — an explicit service function instead of an implicit signal — is often the more honest design for anything that truly must happen: it's visible in the code that calls it, rather than living invisibly in a signal handler that has to be discovered by searching the codebase.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What does a post_save signal handler decorated with @receiver(post_save, sender=Order) actually let you do?

2. In a post_save receiver, what does the `created` argument tell you?

3. Why does Order.objects.bulk_create([...]) never trigger post_save receivers?

4. What's the recommended fix for logic that absolutely must run on both a normal create() and a bulk_create() path?