skip to content

Why do transaction.on_commit callbacks never fire inside a Django TestCase, and how does captureOnCommitCallbacks(execute=True) let you test them?

level: seniorimportance: should knowfreq 45%

answer

  1. the test never really commits
  2. atomic blocks rolled back per test
  3. a classmethod context manager on TestCase
  4. execute=True runs them on exit

basics

~20 s

TestCase runs each test inside an atomic block that is rolled back, so the outer transaction never commits and on_commit callbacks are discarded. Wrapping the code in self.captureOnCommitCallbacks(execute=True) collects those callbacks and calls them when the block exits.

solid answer

~40 s

`transaction.on_commit()` defers a callback until the outermost transaction commits. Django's `TestCase` opens an `atomic` block for the class and another for each test and rolls them back to isolate tests, so there is never a real commit and the callbacks never run — an invite email or a cache purge scheduled that way silently never happens. `TestCase.captureOnCommitCallbacks(using='default', execute=False)` is a context manager that collects the callbacks registered on that connection during the block into a list, filled when the block exits; with `execute=True` it also calls them at that point, emulating the commit, including callbacks registered by those callbacks. The alternative is `TransactionTestCase`, which really commits but resets the database by flushing tables and is much slower.

code

python · 14 lines
python
from functools import partial

from django.db import transaction
from django.http import HttpResponse

from teams.emails import send_invite_email
from teams.models import Invite


def create_invite(request):
    with transaction.atomic():
        invite = Invite.objects.create(email=request.POST['email'])
        transaction.on_commit(partial(send_invite_email, invite.pk))
    return HttpResponse(status=201)

go deeper

for a junior

Remember that Django's TestCase never commits, so anything scheduled with on_commit does not happen unless you use captureOnCommitCallbacks.

for a middle

Explain the class-level and per-test atomic blocks, what the using and execute parameters do, and why the captured list is only populated when the block exits.

for a senior

Choose between emulating the commit, asserting scheduling only, and paying for TransactionTestCase, and spot tests that pass because on_commit was patched to run eagerly.

for a principal

Set a suite-wide convention for commit-dependent side effects so tests stay fast on TestCase while a few deliberate TransactionTestCase tests cover genuine commit and locking behaviour.

## The contract being tested `django.db.transaction.on_commit(func, using=None, robust=False)` registers `func` to run after the current transaction commits. If no transaction is open (autocommit), it runs immediately; if the transaction or the savepoint it was registered in rolls back, it is thrown away. Views use it to send email, enqueue background work or purge caches only once the data those actions depend on is durable. The detailed semantics belong to the transactions topic; what matters here is that the callback needs a **real commit of the outermost transaction**. ## Why a TestCase swallows the callbacks `django.test.TestCase` gets its speed from transactions instead of truncating tables: 1. In `setUpClass` it enters an `atomic` block on every database the class uses, then runs `setUpTestData` inside it. 2. Before each test it enters a second, nested `atomic` block, which runs as a savepoint. 3. After each test it rolls that block back, and after the last test it rolls back the class-level block. The outermost transaction therefore never commits. Every `on_commit` call made by code under test is recorded on the connection and then discarded with the rollback. The test sees no error — just an outbox that stays empty or a mock that was never called. That silent failure is the reason interviewers ask this. ## captureOnCommitCallbacks in detail `captureOnCommitCallbacks` is a **classmethod on `TestCase`** (not on `SimpleTestCase` or `TransactionTestCase`) that returns a context manager: - `using` selects the database alias whose callbacks are captured; the default is `'default'`, so a multi-database project must name the other alias explicitly. - The `as` target is a list that Django **fills as the block exits**, so it is still empty inside the block. - With `execute=False` (the default) the callbacks are only collected; you can assert how many there are and call `callbacks[0]()` yourself. - With `execute=True` each captured callback is called on exit, emulating the commit. If a callback registers another `on_commit` callback, the loop picks it up and runs it as well. - A callback registered with `robust=True` that raises is logged instead of raised; a non-robust callback's exception propagates out of the `with` block and fails the test. - A callback registered inside a savepoint that the code itself rolled back is dropped by Django at that rollback, so it is not in the captured list — which is exactly the production behaviour you want to test. ## Choosing an approach | Approach | What it proves | Cost | |---|---|---| | `captureOnCommitCallbacks(execute=True)` | the side effect happens once the work would commit | cheap; stays inside `TestCase` | | `captureOnCommitCallbacks()` plus `len(callbacks)` | the side effect was scheduled, without running it | cheapest; no side effects | | `TransactionTestCase` | the callback runs after a genuine commit | slow: tables are flushed after every test | | patching `on_commit` to call immediately | only that the function is called | hides rollback and ordering bugs | For most view and service tests the first row is the right default. Reach for `TransactionTestCase` when the behaviour depends on a real commit — another connection must see the rows, or `select_for_update` locking is under test. ## Mistakes that make these tests lie - **Asserting inside the block.** The callbacks run on exit, so `len(mail.outbox)` checked inside the `with` block is still 0. - **Capturing the wrong alias.** Writes routed to another database register callbacks on that connection; capturing `'default'` sees nothing. - **Patching `on_commit` to run callbacks immediately.** The callback then runs before the surrounding block finishes and even when that block rolls back, so a bug that sends email for a failed write passes. - **Blanket `TransactionTestCase`.** Converting whole suites to make callbacks fire trades a small API for minutes of flushing. ## What interviewers listen for The causal chain (TestCase rollback, no commit, discarded callbacks), the name and parameters of the helper, the fact that the list fills on exit, and a reasoned choice between emulating the commit and paying for a real one.

  • When would you use captureOnCommitCallbacks without execute=True?
    When the test should prove a side effect was scheduled without performing it — for example the callback calls an external service. Assert `len(callbacks)`, or inspect the captured callable, and optionally call `callbacks[0]()` yourself later in the test with the service stubbed.
  • Code under test registers an on_commit callback inside a nested atomic block that it then rolls back. Is the callback captured?
    No. When a savepoint rolls back, Django removes the on_commit callbacks registered inside it from the connection's pending list, so they never reach the captured list and `execute=True` does not run them. That mirrors production, where the rolled-back work never triggers its side effect.
  • Why not switch the test class to TransactionTestCase so callbacks run for real?
    It works — each write really commits and callbacks fire — but `TransactionTestCase` resets state by flushing every table after each test, which is far slower than rolling back. Keep it for tests that need genuine commit behaviour, such as another connection seeing the data, and emulate commits elsewhere.

saying these in an interview costs you the question

  • on_commit callbacks run immediately in a TestCase because nothing is committed
  • captureOnCommitCallbacks is available on SimpleTestCase
  • The callbacks list fills as each callback is registered
  • Converting the suite to TransactionTestCase is the standard fix
  • Patching on_commit to call the function at once tests the same behaviour