Django Advanced #5: Signals in depth and post-transaction work
In Intermediate #3 Signals and Middleware you covered the basics; now we build out the toolbox for using signals safely in production. The key concepts are transaction.on_commit and restraint.
The danger of signals — why “restraint”? #
Signals are powerful, but also one of the most-criticized features in large projects. Here’s why:
- Flow is invisible. Looking at the code after
User.save()doesn’t show what happens next — the signal handler lives in another file. - Order is murky. Multiple receivers on the same signal run in registration order, but registration depends on import order.
- Subtle mismatch with transactions.
post_savefires inside the transaction — disastrous when it makes external calls. - Tests leak abstraction. A signal registered in another test can affect yours, causing flakes.
Django’s own recommendation is “prefer explicit calls; use signals only when separation truly justifies it”. Good cases:
- Another app needs to know when our model changes (our app reacts to
auth.Userchanges) - Cache invalidation
- Search indexing (Elasticsearch, Algolia, etc.)
Bad cases:
- Explicit post-processing within the same app (just write the next line after
save) - Core business logic like payments or balances (data breaks if a signal is missed)
Transactions — quick recap #
Django views are AUTOCOMMIT by default. To wrap explicitly:
from django.db import transaction
@transaction.atomic
def transfer(from_id, to_id, amount):
src = Account.objects.select_for_update().get(pk=from_id)
dst = Account.objects.select_for_update().get(pk=to_id)
src.balance -= amount
dst.balance += amount
src.save()
dst.save()All SQL inside the block is one transaction. If an exception is raised, it rolls back automatically.
ATOMIC_REQUESTS
#
DATABASES = {
"default": {
"ENGINE": "...",
"ATOMIC_REQUESTS": True,
...
}
}Turning this on wraps every view in a transaction automatically. A 5xx response triggers rollback. Convenient, but:
- Transactions get longer, increasing lock time
- An external API call inside a view holds the transaction open during the call
For high-traffic sites, explicit atomic is more precise.
Context manager + savepoint #
@transaction.atomic
def outer():
Order.objects.create(...)
try:
with transaction.atomic(): # inner — runs as a savepoint
risky_step()
except SomeError:
# outer transaction stays alive
log_failure()
Order.objects.filter(...).update(status="processed")The inner atomic is a savepoint — partial rollback is possible. The outer transaction is unaffected.
with transaction.atomic(savepoint=False):
...Extremely rare. The default (savepoint=True) is enough.
transaction.on_commit — the standard for post-processing
#
The most important tool. Registers a callback to run after the transaction commits successfully.
from django.db import transaction
@transaction.atomic
def create_order(user, items):
order = Order.objects.create(user=user, total=0)
for item in items:
OrderItem.objects.create(order=order, ...)
transaction.on_commit(lambda: send_confirmation_email(order.id))
return orderFlow:
- Create order, create items
- Commit happens
- Only then
send_confirmation_emailruns
What if the transaction rolls back? The callback is not called. Exactly what we want.
Why this is essential #
@transaction.atomic
def create_order(user, items):
order = Order.objects.create(user=user, total=0)
send_confirmation_email(order.id) # ← dangerous
raise SomeError()The email was sent, but the transaction rolled back. The user gets an email about an order that doesn’t exist. Similar patterns create the same risk in payments, notifications, and external indexing.
on_commit is exactly the fix.
Inside a signal #
post_save signals fire inside the transaction. External calls must be deferred via on_commit.
from django.db import transaction
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Order
@receiver(post_save, sender=Order)
def index_order(sender, instance, created, **kwargs):
if not created:
return
transaction.on_commit(lambda: search_index.add(instance.id))search_index.add only runs after commit. On rollback, the index isn’t touched.
Pairing with Celery #
If you use an async task queue:
@receiver(post_save, sender=Order)
def queue_processing(sender, instance, created, **kwargs):
if not created:
return
transaction.on_commit(lambda: process_order_task.delay(instance.id))on_commit is essential here. Otherwise, when the Celery worker picks up the task and queries the DB, the transaction may not have committed yet, so the order may not be visible. This is a common source of subtle race conditions. Detailed Celery integration is covered in DRF #4.
Chaining / multiple callbacks #
You can register many. They run in registration order.
@transaction.atomic
def f():
...
transaction.on_commit(callback_a)
transaction.on_commit(callback_b)
transaction.on_commit(callback_c)
# After commit: a → b → cCalling outside a transaction #
If you call on_commit outside any transaction, it runs immediately (treated as already committed). So it’s safe to drop into library/util functions — caller wraps in atomic or doesn’t, both work.
Signals — using them in depth #
dispatch_uid — prevent duplicate registration
#
@receiver(post_save, sender=Order, dispatch_uid="order_index_handler")
def index_order(...):
...dispatch_uid is a unique ID. A second registration with the same ID is ignored. Even if the module gets imported twice, the handler is registered only once.
Register in apps.py
#
A pattern that ensures the signals module is auto-imported.
from django.apps import AppConfig
class MyAppConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "myapp"
def ready(self):
from . import signals # noqadefault_app_config = "myapp.apps.MyAppConfig" # Django 3.2+ auto-detectsJust import inside ready(); the decorator handles registration.
⚠ Don’t run DB queries inside ready(). It can be called during migrations.
Frequently used signals #
| Signal | When |
|---|---|
pre_save / post_save | Before/after save() |
pre_delete / post_delete | Before/after delete() |
pre_migrate / post_migrate | Before/after the migrate command |
pre_init / post_init | Model instance creation |
m2m_changed | M2M relation change (add/remove/clear) |
request_started / request_finished | Request start/end |
got_request_exception | Exception inside a view |
user_logged_in / user_logged_out / user_login_failed | Authentication |
m2m_changed — surprisingly tricky
#
from django.db.models.signals import m2m_changed
@receiver(m2m_changed, sender=Post.tags.through)
def on_tags_changed(sender, instance, action, pk_set, **kwargs):
if action in {"post_add", "post_remove", "post_clear"}:
cache.delete(f"post_tags:{instance.id}")action values:
pre_add,post_addpre_remove,post_removepre_clear,post_clear
Mostly you only check post_*. Attaching the same handler to both pre_* and post_* causes it to be called twice — a common mistake.
Custom signals — define your app’s own signals #
Built-in signals aren’t all there is. Define your domain events as signals.
from django.dispatch import Signal
order_paid = Signal() # providing_args was removed in 5.x; use a docstring
order_cancelled = Signal()from .signals import order_paid
def mark_paid(order):
order.status = "paid"
order.save()
order_paid.send(sender=Order, order=order, paid_at=timezone.now())from django.dispatch import receiver
from myapp.signals import order_paid
@receiver(order_paid)
def reward_user(sender, order, paid_at, **kwargs):
transaction.on_commit(lambda: give_points(order.user_id, 100))
@receiver(order_paid)
def notify_seller(sender, order, **kwargs):
transaction.on_commit(lambda: notify_seller_task.delay(order.seller_id))This pattern works well when multiple modules need to react to the same event. But the “flow is invisible” downside still applies — explicit function calls are simpler when possible.
send vs send_robust
#
order_paid.send(sender=Order, order=order) # Any receiver exception → propagated
order_paid.send_robust(sender=Order, order=order) # Catches exceptions, logs only; other receivers continuesend_robust returns a list of (receiver, response_or_exception) tuples. Use it when you want to inspect responses.
For things like email notifications where one receiver dying shouldn’t stop others, use send_robust. If they’re part of a transaction, use send (you want the exception to roll things back).
Testing signals #
Disable signals — mute_signals
#
factory_boy’s tool is the standard.
pip install factory_boyfrom factory.django import mute_signals
from django.db.models.signals import post_save
@mute_signals(post_save)
def test_create_without_signals():
user = User.objects.create(email="a@b.com")
# post_save handlers are not calledUseful for cases where you don’t want side effects — data seeding, fixture loading, etc. Also usable as a class decorator.
Manually disconnect / reconnect #
from django.test import TestCase
from django.db.models.signals import post_save
from myapp.signals import index_order
class MyTest(TestCase):
def setUp(self):
post_save.disconnect(index_order, sender=Order)
def tearDown(self):
post_save.connect(index_order, sender=Order)mute_signals is cleaner, but if you only want to drop a specific receiver, do it manually.
Verify a signal fired #
from unittest.mock import MagicMock
def test_signal_fires():
handler = MagicMock()
order_paid.connect(handler, sender=Order)
try:
mark_paid(order)
handler.assert_called_once()
finally:
order_paid.disconnect(handler, sender=Order)bulk_* and signals
#
The bulk_create, bulk_update, queryset update, and delete methods from #3 do not fire signals. A big trap.
Order.objects.filter(status="pending").update(status="cancelled")
# post_save is not called!For data migrations and similar cases where bypassing signals is intentional, fine. But if signals are core logic in your app, you must manually run post-processing in code when using bulk_*.
ids = list(qs.values_list("id", flat=True))
Order.objects.filter(id__in=ids).update(status="cancelled")
for order_id in ids:
transaction.on_commit(lambda i=order_id: post_cancel_hook(i))Watch the lambda i=order_id default-argument trick — without it, every lambda would capture the last order_id. Closure trap.
Common pitfalls #
1) Calling instance.save() again inside post_save
#
Infinite recursion. Break it with update_fields, handle in pre_save, or use Model.objects.filter(pk=instance.pk).update(...) (doesn’t fire signals).
2) External API call inside a signal holds the transaction #
The one we just saw. Always wrap with on_commit.
3) m2m_changed fires twice
#
Receiving both pre_* and post_* doubles the call. Pick one.
4) Missing signal registration in another app #
If apps.py’s ready() doesn’t trigger the import, signals don’t register. A common regression that static analysis can’t catch.
5) Sync handler called in an async view #
The handler is sync but called in async context. Internally it gets sync_to_async, but if the handler is heavy it can block the event loop. If async views are your main path, prefer explicit calls + await over signals.
Wrap-up #
What you covered this time:
- Signals are powerful but suffer from “invisible flow” — restraint is recommended
- The use cases for
@transaction.atomic, the trade-offs ofATOMIC_REQUESTS - Nested
atomic= savepoint, partial rollback possible transaction.on_commit(callback)— the standard for external calls inside signals/atomic- Use
dispatch_uidto prevent duplicate registration; import inapps.py’sready() m2m_changedaction types and the double-call trap- Custom
Signal()— domain events;sendvssend_robust - Testing:
mute_signals, disconnect/reconnect, verifying calls with MagicMock bulk_*/ querysetupdate/deletedon’t fire signals — handle post-processing explicitly- With Celery: always call
task.delayfrom insideon_commit
In the next post (#6 Django Channels — WebSocket) we layer WebSocket on top of #1 ASGI. AsyncWebsocketConsumer, group broadcast, push from HTTP views, daphne deployment — the home of real-time bidirectional communication.