Django Signals Explained
Learn how Django signals decouple side effects from your models, when to use them, and the common pitfalls that make signals hard to debug.
What you'll learn
- ✓What signals are and how dispatch works
- ✓Common built-in signals like post_save
- ✓How to connect a receiver
- ✓Why signals can hurt debuggability
- ✓When to choose explicit code instead
Prerequisites
- •Comfortable with Django models and apps
What and Why
Signals are Django’s pub-sub system. A sender, usually a model class, emits a signal, and any receiver function listening for that sender gets called. They are useful when you want to react to an event, such as creating a Profile whenever a User is created, without coupling the two models directly.
Mental Model
A signal is a dispatcher object. Receivers register themselves and become subscribers. When the signal is sent, every registered receiver runs synchronously in the same transaction.
User.save() -> post_save signal
|
+--> receiver_1 (create profile)
+--> receiver_2 (send welcome email)
+--> receiver_3 (audit log) Signals are not async by default. They run in the request thread, so anything slow will slow the response.
Hands-on Example
Create a Profile automatically when a User is saved.
# accounts/models.py
from django.db import models
from django.contrib.auth.models import User
class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
bio = models.TextField(blank=True)
Define the receiver.
# accounts/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth.models import User
from .models import Profile
@receiver(post_save, sender=User)
def create_profile(sender, instance, created, **kwargs):
if created:
Profile.objects.create(user=instance)
Hook it up in the app config so it registers on startup.
# accounts/apps.py
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "accounts"
def ready(self):
from . import signals # noqa: F401
Built-in signals you will reach for:
pre_saveandpost_savefor model lifecycle.pre_deleteandpost_deletefor cleanup.m2m_changedfor many-to-many edits.request_startedandrequest_finishedfor per-request hooks.
Custom signals are easy too. Useful when you want a domain event without a database trigger.
# orders/signals.py
import django.dispatch
order_paid = django.dispatch.Signal()
# orders/views.py
from .signals import order_paid
def mark_paid(order):
order.status = "paid"
order.save()
order_paid.send(sender=Order, order=order)
# billing/handlers.py
from django.dispatch import receiver
from orders.signals import order_paid
@receiver(order_paid)
def email_receipt(sender, order, **kwargs):
# send the email here
...
Common Pitfalls
- Receivers that raise exceptions abort the operation. A failing
post_savecan roll back the save in the same transaction. - Hidden side effects. New engineers grep for “send_email” and find nothing because it lives in a signal.
- Duplicate registration. If you import a module twice, your receiver runs twice. Use a unique
dispatch_uid. - Signals do not solve async. They run inline. Use Celery or
transaction.on_commitfor slow work. - Forgetting to filter on
createdinpost_save. Without it, updates also trigger your “new user” code.
Practical Tips
- Prefer explicit calls when the logic belongs to the model.
Order.mark_paid()reads better than a signal listener you have to chase across files. - Use
transaction.on_commit(callback)inside receivers when you want side effects only after the DB commits. - Document each signal at the point it is defined. List the receivers near the sender.
- Test receivers in isolation by calling them directly. Then write at least one integration test that drives the actual save.
- Use
dispatch_uid="accounts.create_profile"on receivers to make them idempotent against double imports.
Wrap-up
Signals shine when the listener really does not belong to the sender’s app, such as cross-cutting audit logging or notifications. For everything else, calling a function is simpler, easier to grep for, and easier to debug. Reach for signals when decoupling is the explicit goal, and use the practical tips above to keep them tame.
Related articles
- Django Django Admin Customization: A Practical Tutorial
Go beyond the default Django admin: customize ModelAdmin classes, list views, search, filters, inline editing, and admin actions to build a usable backoffice your team will actually enjoy.
- Django Django Caching Strategies
Compare per-view, template fragment, low-level, and per-site caching in Django and learn when each pays off.
- Django Django Celery Task Queue Tutorial
A practical guide to wiring Celery into Django for background work, scheduled jobs, and reliable task processing.
- Django Django Class-Based Views: A Practical Tutorial
Understand Django's class-based views by building from View up to ListView and UpdateView. Learn the MRO, mixins, and when CBVs beat function-based views in real projects.