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
senderfiltering — a handler registered withsender=Orderwon’t fire forProduct.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:
- Combine them into a single handler with explicit ordering
- Use
dispatch_uidto 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.
See Also
- Python Django Admin Get an intuitive feel for Django Admin so Python behavior stops feeling unpredictable.
- Python Django Basics Get an intuitive feel for Django Basics so Python behavior stops feeling unpredictable.
- Python Django Celery Integration Why your Django app needs a helper to handle slow jobs in the background.
- Python Django Channels Websockets How Django can send real-time updates to your browser without you refreshing the page.
- Python Django Custom Management Commands How to teach Django new tricks by creating your own command-line shortcuts.