Django Signals — Deep Dive

How Django’s signal dispatcher works

Django’s signal infrastructure lives in django.dispatch.Signal. When you call signal.send(sender, **kwargs), the dispatcher iterates through a list of weak references to connected receiver functions and calls each one in registration order.

# Simplified view of Signal.send()
def send(self, sender, **named):
    responses = []
    for receiver in self._live_receivers(sender):
        response = receiver(signal=self, sender=sender, **named)
        responses.append((receiver, response))
    return responses

Key implementation details:

  • Receivers are stored as weak references by default. If the function is garbage collected, it’s silently removed from the receiver list. This prevents memory leaks but means lambda handlers or unbound closures can disappear unexpectedly.
  • send() collects return values from all receivers but most code ignores them. send_robust() catches exceptions from individual receivers and returns them as part of the response list instead of letting them propagate.
  • Dispatch uses sender filtering — a handler registered with sender=Order won’t fire for Product.save().

Creating custom signals

For domain-specific events that don’t map to model lifecycle, define custom signals:

# signals.py
import django.dispatch

order_fulfilled = django.dispatch.Signal()  # Django 4.0+ no argument needed
payment_failed = django.dispatch.Signal()

# Sending the signal from business logic
from myapp.signals import order_fulfilled

def fulfill_order(order):
    # ... fulfillment logic ...
    order.status = 'fulfilled'
    order.save()
    order_fulfilled.send(
        sender=order.__class__,
        order=order,
        fulfilled_by=current_user
    )

Custom signals give you a typed event contract. Document the keyword arguments each signal provides so handler authors know what’s available.

Transaction-aware signal handling

The biggest production pitfall with post_save is transaction timing. Consider this scenario:

@receiver(post_save, sender=Order)
def notify_warehouse(sender, instance, created, **kwargs):
    if created:
        # This runs before the transaction commits!
        requests.post('https://warehouse.api/orders', json={
            'order_id': instance.pk
        })

If the warehouse API immediately queries your database for order details, the transaction might not be committed yet, and the order won’t be visible. The fix uses transaction.on_commit:

from django.db import transaction

@receiver(post_save, sender=Order)
def notify_warehouse(sender, instance, created, **kwargs):
    if created:
        transaction.on_commit(
            lambda: requests.post('https://warehouse.api/orders', json={
                'order_id': instance.pk
            })
        )

on_commit queues the callable to execute after the current transaction successfully commits. If the transaction rolls back, the callback is discarded.

For Celery task dispatch, this is critical:

@receiver(post_save, sender=Order)
def queue_order_processing(sender, instance, created, **kwargs):
    if created:
        # Wrong: Celery might process before commit
        # process_order.delay(instance.pk)

        # Right: Task queued only after commit
        transaction.on_commit(
            lambda: process_order.delay(instance.pk)
        )

Preventing signal recursion

A post_save handler that calls instance.save() triggers the signal again, creating infinite recursion. Several patterns prevent this:

Pattern 1: Update without triggering save()

@receiver(post_save, sender=Profile)
def compute_score(sender, instance, **kwargs):
    new_score = calculate_score(instance)
    # update() bypasses save() and signals entirely
    Profile.objects.filter(pk=instance.pk).update(score=new_score)

Pattern 2: Disconnect/reconnect

@receiver(post_save, sender=Profile)
def compute_score(sender, instance, **kwargs):
    post_save.disconnect(compute_score, sender=Profile)
    try:
        instance.score = calculate_score(instance)
        instance.save(update_fields=['score'])
    finally:
        post_save.connect(compute_score, sender=Profile)

Pattern 3: Guard flag

@receiver(post_save, sender=Profile)
def compute_score(sender, instance, **kwargs):
    if getattr(instance, '_computing_score', False):
        return
    instance._computing_score = True
    try:
        instance.score = calculate_score(instance)
        instance.save(update_fields=['score'])
    finally:
        instance._computing_score = False

Pattern 1 is cleanest because it avoids re-triggering entirely. Pattern 3 is useful when you need save() side effects but want to break the recursion.

Performance implications at scale

Signal dispatch adds overhead per database operation. In a tight loop creating 10,000 objects, signals fire 10,000 times. Each handler execution adds latency, even if the handler is fast.

# Signals fire for each save
for item in items:
    Product.objects.create(**item)  # 10,000 signal dispatches

# bulk_create skips signals entirely
Product.objects.bulk_create(
    [Product(**item) for item in items],
    batch_size=1000
)

This is a deliberate Django design choice: bulk_create, bulk_update, QuerySet.update(), and QuerySet.delete() do not fire model signals. If your business logic depends on signals, these operations will silently skip it.

For critical invariants, consider database triggers (enforced at the DB level) instead of Django signals for bulk-operation safety.

Signal ordering and priority

Django doesn’t guarantee signal handler execution order across different apps. If handler A must run before handler B, you have two options:

  1. Combine them into a single handler with explicit ordering
  2. Use dispatch_uid to ensure each handler is unique, then control registration order through app loading
@receiver(post_save, sender=Order, dispatch_uid='order_audit_log')
def audit_log(sender, instance, **kwargs):
    AuditLog.objects.create(action='order_saved', object_id=instance.pk)

@receiver(post_save, sender=Order, dispatch_uid='order_notification')
def notification(sender, instance, created, **kwargs):
    if created:
        transaction.on_commit(lambda: notify.delay(instance.pk))

dispatch_uid also prevents duplicate handler registration, which can happen if ready() is called multiple times during testing.

Testing signals

Signals add implicit behavior that tests need to account for. Django provides Signal.disconnect() for isolation:

from django.test import TestCase
from django.db.models.signals import post_save

class OrderTestCase(TestCase):
    def setUp(self):
        post_save.disconnect(notify_warehouse, sender=Order)

    def tearDown(self):
        post_save.connect(notify_warehouse, sender=Order)

    def test_order_creation(self):
        # This test won't trigger the warehouse notification
        order = Order.objects.create(total=100)
        self.assertEqual(order.status, 'pending')

For factory-based testing, libraries like factory_boy provide mute_signals context managers that temporarily disable all handlers for a signal.

The signal vs service layer debate

Many Django experts (including Django’s original creators) recommend a service layer over signals for business logic. Instead of:

Model.save() → post_save signal → handler does business logic

Structure as:

# services.py
def create_order(user, items):
    order = Order.objects.create(user=user)
    order.items.set(items)
    send_confirmation_email(order)
    sync_to_crm(order)
    return order

The service function is explicit, testable, and debuggable. You can see every side effect by reading the function. Signals are still valuable for truly cross-cutting concerns — audit logging, cache invalidation, search index updates — where the emitting code genuinely shouldn’t know about the listener.

The one thing to remember: Django signals are a synchronous observer pattern that runs within the current transaction — use transaction.on_commit for external effects, avoid signals for core business logic, and always account for bulk operations that skip signals entirely.

pythondjangoeventsarchitecture

See Also