In Django, what happens when one of several transaction.on_commit() callbacks raises an exception, and what does robust=True change?
answer
- the data is already committed
- the exception surfaces at block exit
- later callbacks are lost
- robust logs and moves on
basics
~20 sA 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.
solid answer
~40 sCallbacks run after the outermost block has committed, so no callback failure rolls anything back. With the default `robust=False`, an exception from a callback propagates out of the `with transaction.atomic():` exit (or the decorated function), and the remaining callbacks of that transaction are dropped — the same as calling them in sequence yourself. So a view can return a 500 for an order that was in fact saved, and a later receipt email never goes out. `transaction.on_commit(func, robust=True)`, added in Django 4.2, catches any `Exception` from that callback, logs it to the `django.db.backends.base` logger and continues with the next one. Use it for independent, best-effort side effects; leave it off where the caller must notice the failure.
go deeper
Recall that callbacks run after the commit, so their failure never undoes the saved data.
Explain that a default callback's exception stops later callbacks and surfaces at the block's exit, and what robust=True changes.
Diagnose 500s for committed orders and missing follow-ups, and choose robust per callback with logging and alerting in place.
Decide which after-commit effects may fail silently into logs and which need guaranteed delivery through a persisted mechanism.
## Where callbacks run `transaction.on_commit()` callbacks run **after** the outermost `atomic` block commits, inside that block's exit, in the thread that ran the block. The commit has happened by then; a callback cannot make the transaction fail. What a failing callback can do is disrupt what happens *next*. ## Default behaviour: `robust=False` Suppose an order registers three callbacks: send the receipt, enqueue a warehouse task, refresh a cache entry. 1. The order commits. 2. The receipt callback raises (the mail server refused the connection). 3. The exception propagates out of the `atomic` block's exit — the `with` statement raises, or the decorated function raises to its caller. 4. The warehouse and cache callbacks, registered later in the same transaction, are **never called**; the pending list was already taken off the connection. Consequences in a view: the user sees an error page even though the order exists, and a retry by the user may place a second order. The docs describe this as the same behaviour as running the functions sequentially yourself. ## `robust=True` Django 4.2 added the `robust` argument: ```python transaction.on_commit(partial(send_receipt, order.pk), robust=True) transaction.on_commit(partial(warehouse_task.enqueue, order_id=order.pk)) ``` For a robust callback, Django catches any exception derived from `Exception`, logs it with a traceback to the **`django.db.backends.base`** logger ("Error calling … in on_commit() …"), and carries on with the next callback. It applies per callback, so you choose it for each registration. | | `robust=False` (default) | `robust=True` | |---|---|---| | Transaction | Already committed | Already committed | | Exception | Propagates from the block's exit | Caught and logged | | Later callbacks | Not run | Run | | Caller notices | Yes, as an exception | Only via logs | Outside a transaction, where `on_commit()` runs the callable immediately, `robust=True` likewise logs instead of raising. ## Choosing per callback - **Robust**: independent, best-effort effects — a receipt email, a cache refresh, an analytics ping. One failing should not block the others, and the caller already succeeded. - **Not robust**: a step whose failure the caller must act on, or a callback that later callbacks depend on. - **Neither is a retry mechanism**: a robust callback's failure is only a log line. Monitor that logger, or move critical work to a persisted outbox. ## Testing the behaviour `TestCase.captureOnCommitCallbacks(execute=True)` runs captured callbacks when its context exits, and honours `robust`: a robust callback's exception is logged there too, while a non-robust one raises out of the context manager. That lets a test assert both that the view succeeded and that a failing receipt did not stop the warehouse task. ## Diagnosing it in production - Symptom: 500 responses whose tracebacks start in a mail or queue client but whose orders exist in the database. - Symptom: some orders have receipts but no warehouse task — the tasks registered after a failing callback. - Fix: make independent callbacks robust, order dependent ones deliberately, and alert on the `django.db.backends.base` logger. ## Why robust is not the default Making every callback robust would hide failures: the code after the block would carry on as if the side effect happened, and the only evidence would be a log line. Django keeps the default equivalent to calling the functions yourself in sequence, so an unexpected error is loud. `robust=True` is an explicit statement that this particular effect is best-effort and independent of the rest — a decision worth making callback by callback rather than project-wide.
- Can a failing on_commit callback roll back the transaction that registered it?No. Callbacks run only after the outermost block has committed, and they are not part of the transaction. A failure can at most raise to the caller and stop later callbacks; the data stays committed.
- Which exceptions does robust=True swallow?Anything derived from Python's `Exception`, which it logs to the `django.db.backends.base` logger. `BaseException` subclasses that are not `Exception`, such as `KeyboardInterrupt` or `SystemExit`, still propagate.
saying these in an interview costs you the question
- A callback exception rolls the committed order back
- The remaining callbacks still run by default after one fails
- robust=True retries the failed callback later
- robust=True makes the callback part of the transaction