Filter concepts by levelShowing all levels.

Django · Section 42

Transactions + Signals

Level
advanced
Read
12 min
Concepts
1

A signal fires the instant its triggering call runs, completely unaware of whether it's inside a still-open transaction that might later roll back — transaction.on_commit(func) is Django's explicit way to defer a callable until the transaction actually, successfully commits, discarding it entirely on rollback. This matters for anything with an effect outside the database (email, an external API call, enqueueing a background job), since none of those can be cleanly undone the way a database write can — the roadmap's own explicit warning: do not accidentally send a notification for a transaction that later rolls back.

What is true here

  1. A signal receiver always fires immediately, transaction-unaware — on_commit() must be called explicitly inside it to defer the actual side effect.
  2. transaction.on_commit(func) runs func only after the current transaction commits; on rollback, it is discarded entirely, never run.
  3. Anything with an effect outside the database (email, external API, background job) belongs behind on_commit(), never triggered directly inside an open transaction.
  4. A nested atomic() (savepoint) still defers on_commit() to the OUTERMOST transaction's commit, not the inner savepoint completing.

What you will be able to do

  • Recognize when a signal receiver's side effect needs to be deferred with on_commit()
  • Avoid sending a notification, calling an external service, or enqueueing a job for a transaction that later rolls back
  • Reason correctly about on_commit() timing inside nested atomic() blocks

When signals execute, and on_commit()

Signals fire transaction-unaware by default — on_commit() is the explicit fix for anything with an external effect.

When signals execute relative to a transaction, and on_commit()

coreadvanced

A model signal (post_save, post_delete, etc.) fires the instant its triggering call runs — completely unaware of whether that call is inside a still-open transaction.atomic() block that might later roll back. transaction.on_commit(func) is Django's way to defer a callable specifically until the current transaction actually, successfully commits — if the transaction rolls back instead, the callable is simply discarded, never run. This matters most for anything with an effect OUTSIDE the database: sending an email, calling an external API, or enqueueing a background job — each of these cannot be "rolled back" the way a database write can, so triggering them from inside a transaction that might still fail risks a real, unrecoverable side effect for data that was never actually saved.

Think of it as

The core problem on_commit() solves is a mismatch in "undo-ability": a database write inside transaction.atomic() can be cleanly rolled back if something later in the same block fails, but an email already sent, an API call already made, or a background job already enqueued cannot be un-sent, un-called, or un-enqueued. A signal receiver (or any code) that triggers such an effect DURING the transaction — before Django even knows whether it will commit — creates exactly that risk: the side effect happens unconditionally, but the data it was reacting to might not exist a moment later. on_commit() resolves this by registering the callable to run only once the transaction manager confirms a successful COMMIT, and to be silently dropped on ROLLBACK — turning an unconditional side effect into one correctly conditioned on the outcome it was actually reacting to. This is also why the pairing "transactions + signals" is its own topic rather than folded entirely into either: a signal receiver looks like ordinary code reacting to a save, but its ACTUAL correct behavior for anything external depends on transaction state the receiver itself has no direct visibility into unless it explicitly asks for it via on_commit().

python
from django.db import transaction

transaction.on_commit(lambda: external_call(obj.id))

What we're doing: A checkout view where a failed shipping-address validation later in the same transaction must prevent an already-triggered payment notification from actually firing.

orders/views.pypython
def checkout(request):
    with transaction.atomic():
        order = Order.objects.create(customer=request.user, total=cart.total)
        transaction.on_commit(lambda: notify_payment_processor.delay(order.id))

        if not validate_shipping_address(request.POST):
            raise ValidationError("Invalid shipping address")   # triggers rollback

    return redirect("order-confirmation", order.id)
4
Registered immediately after create(), but NOT executed here — it only actually runs if the with block exits normally, i.e. the transaction commits.
8
Raising inside the atomic() block rolls back the Order creation — and because of on_commit(), the payment-processor notification that was already "registered" for this Order never fires either.

Why this works: Without on_commit(), calling notify_payment_processor.delay(order.id) directly at line 4 would enqueue the notification unconditionally — including in the exact case shown here, where the order itself is about to be rolled back due to invalid shipping data, leaving the payment processor notified about an order that was never actually created.

Triggering a background task or external call directly inside a transaction, instead of via on_commit()

Wrong

python
def checkout(request):
    with transaction.atomic():
        order = Order.objects.create(customer=request.user, total=cart.total)
        send_confirmation_email.delay(order.id)   # fires immediately, transaction-unaware
        validate_and_charge(order)   # if this raises, the email was already sent

Better

python
def checkout(request):
    with transaction.atomic():
        order = Order.objects.create(customer=request.user, total=cart.total)
        transaction.on_commit(lambda: send_confirmation_email.delay(order.id))
        validate_and_charge(order)

What you see: A customer receives an order confirmation email for an order that, moments later in the same request, fails validation and rolls back entirely — a real, visible inconsistency (an email referencing an order id that doesn't exist in the database) rather than a silent internal bug.

Why: .delay() (or any direct call) has no awareness of the enclosing transaction.atomic() block — it runs the instant that line executes, regardless of what happens later in the same block. on_commit() is the only mechanism that actually conditions the side effect on the transaction's real, final outcome.

Calling directly vs. deferring with on_commit()

Direct call inside atomic()

  • +send_email.delay(order.id) fires immediately
  • +Transaction-unaware — runs even if the block later rolls back
  • +Risk: notifies about data that never actually persisted

transaction.on_commit(...)

  • Registered now, runs only after a real COMMIT
  • Discarded entirely if the transaction rolls back
  • Correct for email, external API calls, background jobs
  • Direct call inside atomic()
    • send_email.delay(order.id) fires immediately
    • Transaction-unaware — runs even if the block later rolls back
    • Risk: notifies about data that never actually persisted
  • transaction.on_commit(...)
    • Registered now, runs only after a real COMMIT
    • Discarded entirely if the transaction rolls back
    • Correct for email, external API calls, background jobs

Which side effects need on_commit(), and why

Which side effects need on_commit(), and why
Side effectNeeds on_commit() because
Sending an email/notificationcannot be un-sent if the transaction rolls back
Calling an external APIthe external system now believes something happened that never actually persisted
Enqueueing a background jobthe job may run and query for data that isn't there yet, or never will be
A plain database write inside the same transactionnot needed — rollback already cleanly undoes it

Together

python
@receiver(post_save, sender=Order)
def on_order_created(sender, instance, created, **kwargs):
    if created:
        transaction.on_commit(lambda: send_confirmation_email.delay(instance.id))

Remember: A signal (or any code) fires immediately, transaction-unaware — transaction.on_commit(func) is the explicit, opt-in way to defer a callable until the transaction actually commits, discarding it entirely on rollback. Anything with an effect outside the database (email, external API, background job) should go through on_commit(), never called directly inside an open transaction. Nested atomic() blocks still defer to the OUTERMOST transaction's commit, not the inner savepoint's completion.

See also: side effects and when to avoid signals · atomic and nested blocks · on commit and durable

Advertisement