skip to content

Commit Hooks

transaction.on_commit() defers a callback until the outer block commits and drops it on rollback, so tasks and emails never fire for lost writes. Interviewers ask how tests see these callbacks.

part ofDjangooverview, primer and where to startread it →
on this pageshow

explore

questions

4

In Django, what does transaction.on_commit() do, and why send an order's receipt email through it rather than straight after save()?

level: juniorimportance: must knowfreq 55%

answer

  1. rolled-back orders must not email
  2. registered now, called later
  3. discarded on rollback
  4. no arguments, so bind them

basics

~20 s

transaction.on_commit() registers a callable to run after the current transaction commits and drops it if the transaction rolls back. Sending the receipt from it means no customer is emailed about an order that was never saved.

solid answer

~40 s

Inside `transaction.atomic`, `save()` sends SQL immediately but nothing is committed until the outermost block exits, so a later failure can roll the order back. An email sent right after `save()` cannot be recalled: the customer gets a receipt for an order that does not exist. `transaction.on_commit(func)` stores the callable on the connection and Django calls it only after the outermost block commits; if the block rolls back, the callable is discarded. Callbacks take no arguments, so bind them with `functools.partial(send_receipt, order.pk)`. Outside any atomic block, in Django's default autocommit mode, the callable runs immediately. The callback is not part of the transaction: if the email fails, the order stays committed.

code

python · 17 lines
python
from functools import partial

from django.core.mail import send_mail
from django.db import transaction


def send_receipt(order_id):
    order = Order.objects.get(pk=order_id)
    send_mail("Your receipt", f"Order {order.pk}: {order.total}", None, [order.email])


def place_order(cart, email):
    with transaction.atomic():
        order = Order.objects.create(email=email, total=cart.total)
        add_lines(order, cart)  # if this raises, no email is sent
        transaction.on_commit(partial(send_receipt, order.pk))
    return order

go deeper

for a junior

Recall that on_commit delays a callable until the transaction commits and discards it on rollback, and that it takes a no-argument callable.

for a middle

Explain immediate execution in autocommit, the ordering of callbacks, and why functools.partial with a primary key beats a lambda or an instance.

for a senior

Point out that callbacks are synchronous and can be lost after the commit, and decide which side effects need a persisted outbox instead.

for a principal

Set a team rule for where side effects are triggered from, and when best-effort after-commit delivery is acceptable versus a transactional outbox.

## The problem: side effects you cannot undo Inside a `transaction.atomic()` block, Django's `save()` and `create()` send their SQL to the database straight away, but the changes are only **committed** when the outermost block exits normally. Anything later in the block — creating order lines, charging a wallet, a validation error — can still raise, and then the whole transaction is rolled back. Database work rolls back. An email does not. If `place_order()` sends the receipt right after `Order.objects.create()` and the next line fails, the customer holds a receipt for an order that never existed. The same applies to enqueueing a background task, invalidating a cache entry or calling a webhook. ## What `on_commit` does `django.db.transaction.on_commit(func, using=None, robust=False)` registers a callable against the current transaction on one database alias: | Situation when you call it | What happens to `func` | |---|---| | Inside an `atomic` block (or an `ATOMIC_REQUESTS` view) | Stored; called after the outermost block commits | | The transaction is rolled back | Discarded, never called | | Outside any block, autocommit on (Django's default) | Called immediately | | Autocommit turned off and no `atomic` block | `TransactionManagementError` | Callbacks run in the order they were registered. They are called **after** the commit and after autocommit is restored on the connection, so a query inside a callback runs in ordinary autocommit mode rather than reopening the finished transaction. ## Writing the callback The callable is invoked with **no arguments**. Bind what it needs with `functools.partial`: ```python from functools import partial from django.core.mail import send_mail from django.db import transaction def send_receipt(order_id): order = Order.objects.get(pk=order_id) send_mail("Your receipt", render_receipt(order), None, [order.email]) def place_order(cart, email): with transaction.atomic(): order = Order.objects.create(email=email, total=cart.total) add_lines(order, cart) transaction.on_commit(partial(send_receipt, order.pk)) return order ``` Passing the primary key rather than the instance keeps the callback honest: it reads the committed row. A `lambda` works too, but in a loop it captures the loop *variable*, not its value, so every callback sees the last item — `partial` binds the value at registration time. ## What it does not promise - **Not part of the transaction.** The callback runs after the commit; if the mail server is down, the order is still committed. Django's docs point to two-phase commit if a failure must undo the transaction, and a persisted outbox is the usual alternative. - **Not background work.** The callback runs synchronously in the same thread, at the moment the outer block exits. A slow SMTP call delays the code after the block — in a view, the response. - **Not durable.** If the process dies between the commit and the callback, the callback is lost. - **Per alias.** A callback registered for `using="default"` is tied to that connection's transaction only. ## Interview checklist - Say *when* it runs (after the outermost commit) and *when it doesn't* (rollback). - Mention immediate execution outside a block — a common surprise in scripts and tests. - Use `partial` and pass identifiers, not instances. - Name the limits: synchronous, after-commit, can be lost. ## Where it fits among the alternatives | Place to send the receipt | Emailed for a rolled-back order? | Email can be lost after commit? | |---|---|---| | Right after `save()` inside the block | Yes | No | | After the `with` block, in plain code | Yes, if a caller's outer block later rolls back | Yes | | `transaction.on_commit(...)` inside the block | No | Yes | Placing the call after the `with` block looks equivalent, but it breaks as soon as a caller wraps `place_order()` in its own `atomic` block or the view runs under `ATOMIC_REQUESTS`: "after the inner block" is then still inside the transaction. `on_commit` always waits for the *outermost* commit, which is why it is the idiom rather than code placement.

  • What happens if you call transaction.on_commit() in a management command that never opens an atomic block?
    Django is in autocommit mode there, so there is no open transaction to wait for and the callable runs immediately, inside the `on_commit()` call. Only if autocommit had been switched off manually, with no atomic block, would Django raise `TransactionManagementError`.
  • Why pass order.pk to the callback instead of the Order instance?
    The callback runs later and should act on what was committed. Re-reading by primary key picks up the committed row and any changes made after registration, and a bound identifier is cheap and easy to log. Passing the instance risks acting on stale in-memory attributes.

It is like handing a letter to a clerk who posts it only once the sale is final: cancel the sale and the letter is shredded unsent.

saying these in an interview costs you the question

  • on_commit runs the callback in a background thread
  • A failing on_commit callback rolls the order back
  • Callbacks are still called after a rollback, with an error flag
  • Sending the email after save() is safe because save() commits inside atomic
  • on_commit raises an error when no transaction is open
open as a page

In Django, when exactly does a transaction.on_commit() callback registered inside a nested atomic block run, and when is it dropped?

level: middleimportance: should knowfreq 38%

basics

~20 s

A callback registered in a nested atomic block runs only after the outermost block commits. If the inner block rolls back to its savepoint, callbacks registered inside it are dropped while earlier ones survive; a full rollback drops them all.

open as a page

In Django, why can a background task enqueued inside transaction.atomic() fail to find the order it was given, and how does on_commit() fix it?

level: seniorimportance: should knowfreq 45%

basics

~20 s

A task handed to a separate worker inside an atomic block can run before the transaction commits, so the worker cannot see the new row, or the order may roll back. Enqueue it with transaction.on_commit(partial(task.enqueue, order_id=order.pk)).

open as a page

In Django, what happens when one of several transaction.on_commit() callbacks raises an exception, and what does robust=True change?

level: seniorimportance: nice to knowfreq 26%

basics

~20 s

A raising on_commit callback cannot undo the commit, but by default its exception propagates from the atomic block's exit and the callbacks registered after it never run. With robust=True the exception is logged and the next callbacks still run.

open as a page