Signals — and When Not to Use Them
Decouple side effects with pre_save/post_save/m2m_changed, and know the senior's case against overusing them.
Signals let one piece of code react to an event elsewhere without a direct call. When a User is created, a signal receiver can create their Profile; when an Order is saved, a receiver can invalidate a cache. The sender emits, any number of receivers listen.
The signals you'll actually use
pre_save/post_save— before/after a model is saved (with acreatedflag).pre_delete/post_delete— around deletions.m2m_changed— when a ManyToMany set is modified (the only way to hook M2M changes).
from django.db.models.signals import post_save
from django.dispatch import receiver
@receiver(post_save, sender=User)
def create_profile(sender, instance, created, **kwargs):
if created:
Profile.objects.create(user=instance)Register receivers in your app config's ready() method so they load exactly once. Always accept **kwargs — Django passes extra arguments and will keep adding them.
The senior caveat: signals make control flow invisible. A save in one app triggering an email in another, with nothing in the call site to hint at it, is a debugging nightmare at 2am. Prefer an explicit service function or an overriddensave()when the logic belongs to the model. Reserve signals for genuine cross-app decoupling — especially reacting to models you don't own, likeUseror a third-party app.
Example
# apps.py — wire receivers up exactly once, on app load.
from django.apps import AppConfig
class OrdersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'orders'
def ready(self):
from . import signals # noqa: F401 (import registers receivers)
# signals.py
from django.db import transaction
from django.db.models.signals import post_save, m2m_changed
from django.dispatch import receiver
from .models import Order
from .tasks import send_confirmation, reindex_search
@receiver(post_save, sender=Order)
def on_order_saved(sender, instance, created, **kwargs):
if not created:
return
# Defer the side effect until the surrounding transaction commits.
transaction.on_commit(lambda: send_confirmation.delay(instance.pk))
@receiver(m2m_changed, sender=Order.products.through)
def on_products_changed(sender, instance, action, pk_set, **kwargs):
# Only react once the DB actually changed, not on the 'pre_' phases.
if action in {'post_add', 'post_remove', 'post_clear'}:
instance.recalculate_total()
transaction.on_commit(lambda: reindex_search.delay(instance.pk))
# To make a signal robust to being registered twice, give it a dispatch_uid:
# @receiver(post_save, sender=Order, dispatch_uid='orders.on_order_saved')When to use it
- A developer connects to the post_save signal on the User model to automatically create a UserProfile record.
- A team uses the post_delete signal to remove associated S3 files when a media object is deleted.
- A developer uses pre_save to automatically generate a unique slug from the title before a Post is saved.
More examples
Auto-create UserProfile on user save
Listens for new User creation and automatically creates a linked UserProfile record.
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth import get_user_model
from .models import UserProfile
User = get_user_model()
@receiver(post_save, sender=User)
def create_profile(sender, instance, created, **kwargs):
if created:
UserProfile.objects.create(user=instance)pre_save to auto-generate slug
Generates a URL slug from the post title before saving if one has not already been set.
from django.db.models.signals import pre_save
from django.dispatch import receiver
from django.utils.text import slugify
@receiver(pre_save, sender=Post)
def set_slug(sender, instance, **kwargs):
if not instance.slug:
instance.slug = slugify(instance.title)post_delete to remove related file
Removes the physical file from disk when a Document record is deleted from the database.
from django.db.models.signals import post_delete
from django.dispatch import receiver
import os
@receiver(post_delete, sender=Document)
def delete_file(sender, instance, **kwargs):
if instance.file:
if os.path.isfile(instance.file.path):
os.remove(instance.file.path)
Discussion