← ~/logs LOG-009

>Reliable Django Signals

Making Django signals reliable using the transactional outbox pattern and Django 6 tasks framework.

Haki Benita’s talk at DjangoCon Europe 2026 on why Django signals are unreliable and how to fix them with the transactional outbox pattern.

The problem

Django signals have no delivery guarantees. Receiver failures crash the sender. send_robust() swallows exceptions but receivers still run inside the sender’s transaction. Long-running receivers block the transaction. Side effects can’t be rolled back — if a receiver sends an SMS then the transaction rolls back, the user gets a message for something that didn’t happen.

The worst case: moving the signal outside the transaction creates a gap. If the process crashes after commit but before the signal fires, receivers never execute. The payment succeeds but the order stays pending forever.

The fix: transactional outbox

Enqueue signal receivers as tasks inside the sender’s transaction. The tasks are committed atomically with the data change. A separate worker picks them up after commit.

class Signal(DjangoSignal):
    def send_reliable(self, sender, **named):
        if not self.receivers or self.sender_receivers_cache.get(sender) is NO_RECEIVERS:
            return
        sync_receivers, async_receivers = self._live_receivers(sender)
        for receiver in sync_receivers:
            execute_task_signal_receiver.enqueue(
                receiver_qualname=callable_to_qualname(receiver),
                named=named,
            )

Send it inside the transaction:

class PaymentProcess(models.Model):
    @classmethod
    def set_status(cls, id, *, succeeded):
        with transaction.atomic():
            payment_process = cls.objects.select_for_update().get(id=id)
            payment_process.status = 'succeeded' if succeeded else 'failed'
            payment_process.save()

            # Task is committed atomically with the data
            signals.payment_process_completed.send_reliable(
                sender=None,
                payment_process_id=payment_process.id,
            )

If the transaction rolls back, the task rows disappear. If it commits, the worker picks them up. Failed receivers can be retried.

Django 6 makes it easy

Django 6’s tasks framework with django-tasks-db gives you a database-backed task queue. No Celery, no Redis:

TASKS = {
    "default": {
        "BACKEND": "django_tasks_db.DatabaseBackend",
        "ENQUEUE_ON_COMMIT": False,
    }
}

For tests, swap to ImmediateBackend so tasks run synchronously:

@override_settings(TASKS={'default': {'BACKEND': 'django_tasks.backends.immediate.ImmediateBackend'}})
class OrderTestCase(TestCase):
    def test_order_completed_on_successful_payment(self):
        order = Order.create(amount=100_00)
        PaymentProcess.set_status(order.payment_process_id, succeeded=True)
        order.refresh_from_db()
        self.assertEqual(order.status, 'completed')

Key takeaways

  • Standard Django signals have no delivery guarantees
  • The transactional outbox pattern enqueues receiver tasks in the same database transaction as the data change
  • Django 6’s tasks framework makes this straightforward — no external queue needed
  • Polling is still valuable as a fallback for anything the signal missed

Slides | Experiment code