skip to content

In Django, what does transaction.atomic(durable=True) guarantee, and why does it raise RuntimeError when it is nested inside another block?

level: seniorimportance: nice to knowfreq 22%

answer

  1. must be the outermost block
  2. exit without error means committed
  3. caller cannot silently absorb it
  4. TestCase's own block is exempt

basics

~20 s

atomic(durable=True) guarantees the block is the outermost one, so its changes are committed when it exits without error. Nesting it would turn it into a savepoint a caller could still roll back, so Django raises RuntimeError instead.

solid answer

~50 s

A normal `atomic` block's success means nothing on its own: if a caller already opened a block, the inner one is just a savepoint and the caller can still roll it back. `durable=True` asserts the opposite contract — this block must be the outermost, so when it exits normally the data *is* committed. If any other `atomic` block is already active on that database, entering it raises `RuntimeError("A durable atomic block cannot be nested within another atomic block.")`. It suits a function such as capturing a wallet payment, where the next step — notifying a provider, returning a receipt — assumes the write is durable. In `django.test.TestCase`, the per-test transaction is exempt, so a durable block called directly from a test still works, but nesting it inside another block in the test raises as in production. Its counterpart for the other direction is `transaction.set_rollback(True)`, which forces a rollback without raising.

code

python · 14 lines
python
from django.core.management.base import BaseCommand
from django.db import transaction


class Command(BaseCommand):
    def add_arguments(self, parser):
        parser.add_argument("--dry-run", action="store_true")

    def handle(self, *args, **options):
        with transaction.atomic():
            moved = rebalance_wallets()
            self.stdout.write(f"would move {moved} transfers")
            if options["dry_run"]:
                transaction.set_rollback(True)  # discard without raising

go deeper

for a junior

Recall that durable=True means the block must be the outermost one, and that nesting it raises RuntimeError.

for a middle

Explain why an ordinary nested block cannot promise a commit, and how the TestCase exemption keeps durable functions testable.

for a senior

Choose the few functions that should be durable, and foresee the conflict with ATOMIC_REQUESTS views and callers that open their own blocks.

for a principal

Weigh composability against guaranteed commits across a codebase, and document which layer is allowed to own the outermost transaction.

## The problem durable solves `transaction.atomic` composes by nesting: called inside another block it creates a **savepoint**, and only the outermost block commits. That is convenient, but it means a function cannot know, from its own code, whether its successful exit actually committed anything. A caller — or `ATOMIC_REQUESTS`, which wraps the whole view — may still roll everything back later. For most code that is fine. It is not fine when the next line assumes the data is durable: capturing a payment on a wallet and then telling an external provider "done", issuing a receipt, or recording a ledger entry that a second process will read immediately. ## What `durable=True` does `atomic(durable=True)` adds one check on entry: - if no `atomic` block is active on that database alias, it behaves like an ordinary outermost block — open, then commit or roll back on exit; - if **any** other block is already active — the only exception being the per-test block that `django.test.TestCase` opens — it raises `RuntimeError` with the message "A durable atomic block cannot be nested within another atomic block." The guarantee is therefore: *if this block exits without an exception, its changes are committed*. The error turns a silent semantic change (becoming a savepoint) into a loud failure at the call site. ```python from django.db import transaction @transaction.atomic(durable=True) def capture_payment(wallet_id, amount): ... # debit the wallet and write the ledger row @transaction.atomic def checkout(order): capture_payment(order.wallet_id, order.total) # RuntimeError: nested ``` ## Interaction with other features | Situation | Result | |---|---| | Called with no active block | Normal outermost transaction | | Called inside another `atomic` block | `RuntimeError` | | Called from a view under `ATOMIC_REQUESTS` | `RuntimeError`, because the view is already wrapped | | Called directly inside a `django.test.TestCase` test | Allowed: the per-test block is exempt | | Nested in another block inside a `TestCase` test | `RuntimeError`, same as production | The `TestCase` exemption matters because every `TestCase` test already runs inside an `atomic` block; without it, no test could call a durable function. Since Django 4.1 the nested case raises inside `TestCase` too, so tests catch the same mistake production would. ## The opposite lever: `set_rollback()` `transaction.set_rollback(True)` marks the innermost enclosing savepoint-backed block (or the whole transaction) for rollback **without raising an exception**. Typical uses: 1. a "dry run" option on a management command that performs every write, reports the result, then discards it; 2. a view that must return a normal response yet undo its writes. `set_rollback(False)` clears the flag and is only safe after you have rolled back to a known-good savepoint yourself. Both `get_rollback()` and `set_rollback()` raise `TransactionManagementError` outside an atomic block. ## When to reach for durable - Use it on a small number of functions whose callers must never wrap them. - Do not sprinkle it everywhere: it removes composability, and a project running `ATOMIC_REQUESTS` cannot call durable functions from its views at all. - Pair it with commit hooks when the follow-up side effect should run only after the commit. ## How to answer in an interview A good answer states the contract in one sentence — "if it exits without error, it is committed" — then explains why a normal block cannot promise that (nesting turns it into a savepoint), names the exception (`RuntimeError`) and the test-time exemption, and closes with a trade-off: durability is bought by giving up composability, so it belongs on a handful of boundary functions, not on every service.

  • Why can a project with ATOMIC_REQUESTS enabled not call a durable function from its views?
    The handler already wraps the view in an atomic block for that database, so any `atomic(durable=True)` inside it is nested and raises `RuntimeError`. Such a view must be decorated with `non_atomic_requests`, or the work moved to code that runs outside the request transaction.
  • How do you make an atomic block roll back without raising an exception?
    Call `transaction.set_rollback(True)` inside it. On exit the innermost block with a savepoint, or the outermost block, rolls back even though the code finished normally — useful for dry runs. Outside an atomic block the call raises `TransactionManagementError`.

saying these in an interview costs you the question

  • durable=True makes the database fsync the block to disk
  • A durable block nested in another block becomes a savepoint
  • durable functions cannot be tested with TestCase at all
  • set_rollback(True) raises an exception to trigger the rollback