Django Advanced #5: Signals in depth and post-transaction work

8 min read

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_save fires 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.User changes)
  • 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:

@transaction.atomic
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 #

settings.py
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 #

Nested atomic = 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.

Explicit disable
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.

Basic
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 order

Flow:

  1. Create order, create items
  2. Commit happens
  3. Only then send_confirmation_email runs

What if the transaction rolls back? The callback is not called. Exactly what we want.

Why this is essential #

🚫 Direct call inside atomic
@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.

signals.py
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:

With Celery
@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.

Multiple callbacks
@transaction.atomic
def f():
    ...
    transaction.on_commit(callback_a)
    transaction.on_commit(callback_b)
    transaction.on_commit(callback_c)
    # After commit: a → b → c

Calling 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 #

signals.py
@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.

myapp/apps.py
from django.apps import AppConfig

class MyAppConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "myapp"

    def ready(self):
        from . import signals    # noqa
myapp/__init__.py
default_app_config = "myapp.apps.MyAppConfig"   # Django 3.2+ auto-detects

Just import inside ready(); the decorator handles registration.

Don’t run DB queries inside ready(). It can be called during migrations.

Frequently used signals #

SignalWhen
pre_save / post_saveBefore/after save()
pre_delete / post_deleteBefore/after delete()
pre_migrate / post_migrateBefore/after the migrate command
pre_init / post_initModel instance creation
m2m_changedM2M relation change (add/remove/clear)
request_started / request_finishedRequest start/end
got_request_exceptionException inside a view
user_logged_in / user_logged_out / user_login_failedAuthentication

m2m_changed — surprisingly tricky #

m2m_changed
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_add
  • pre_remove, post_remove
  • pre_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.

myapp/signals.py — define
from django.dispatch import Signal

order_paid = Signal()    # providing_args was removed in 5.x; use a docstring
order_cancelled = Signal()
Publish
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())
Subscribe
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 #

The difference
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 continue

send_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.

Install
pip install factory_boy
tests
from 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 called

Useful for cases where you don’t want side effects — data seeding, fixture loading, etc. Also usable as a class decorator.

Manually disconnect / reconnect #

setUp/tearDown
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 #

mock
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.

🚫 Assuming signals
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_*.

✅ Explicit post-processing
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 of ATOMIC_REQUESTS
  • Nested atomic = savepoint, partial rollback possible
  • transaction.on_commit(callback) — the standard for external calls inside signals/atomic
  • Use dispatch_uid to prevent duplicate registration; import in apps.py’s ready()
  • m2m_changed action types and the double-call trap
  • Custom Signal() — domain events; send vs send_robust
  • Testing: mute_signals, disconnect/reconnect, verifying calls with MagicMock
  • bulk_* / queryset update/delete don’t fire signals — handle post-processing explicitly
  • With Celery: always call task.delay from inside on_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.

X