Skip to content
C Codeloom
Django

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.

·4 min read · By Codeloom
Intermediate 8 min read

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)
Signal dispatch flow

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_save and post_save for model lifecycle.
  • pre_delete and post_delete for cleanup.
  • m2m_changed for many-to-many edits.
  • request_started and request_finished for 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_save can 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_commit for slow work.
  • Forgetting to filter on created in post_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.