Filter concepts by levelShowing all levels.

Django · Transactions

Transactions

atomic(), the broken-transaction trap, retry behavior, and on_commit()

  • Nested atomic() = a savepointthe outermost block is the true commit boundary
  • Catching an error INSIDE atomic() breaks itcatch from outside, or hit TransactionManagementError next
  • Retry serialization failures/deadlocksa real IntegrityError will just fail identically on retry
  • on_commit() defers until commit is certainnever call an email/webhook directly inside atomic()

1.transaction.atomic() and nested blocks

Django runs in autocommit mode by default — every query commits immediately on its own. transaction.atomic() (as a decorator or with block) groups everything inside into one real transaction: all-or-nothing, committed together on success, rolled back together on any exception. A NESTED atomic() block creates a SAVEPOINT, not a separate transaction — if the inner block raises, only ITS changes roll back (to the savepoint); the outer block continues and can still commit its own, earlier work.

Key features

  • Django's default is autocommit — atomic() is what turns a group of statements into one real transaction instead of many independent auto-committed ones
  • atomic() works as both a decorator (@transaction.atomic) wrapping a whole function, and a context manager (with transaction.atomic():) wrapping just part of one
  • A NESTED atomic() block creates a SAVEPOINT — an inner block's rollback (via an exception) undoes only that inner block's changes, not the outer block's already-completed work
  • If the OUTER atomic() block itself raises (uncaught), everything rolls back — including changes from inner blocks that had already "succeeded" and exited normally
  • The outer-most atomic() block is the true transaction boundary — commit only happens when THAT block exits without an exception

What rolls back, at which nesting level

What rolls back, at which nesting level
ScenarioWhat rolls back
Exception inside an inner atomic() block, caught there or just outside itonly the inner block's changes (to its savepoint)
Exception inside the outer atomic() block, uncaughteverything — including any inner blocks that already committed to their savepoint
No exception anywherenothing rolls back — the outer block commits everything on exit
A nested atomic() is a savepoint, not a separate transaction

Outer atomic()

the true commit boundary — its rollback undoes everything, inner blocks included

Inner atomic() (savepoint)

its own rollback stays scoped to just its own changes

Autocommit (no atomic())

default — every query commits immediately on its own

  1. Outer atomic() — the true commit boundary — its rollback undoes everything, inner blocks included
  2. Inner atomic() (savepoint) — its own rollback stays scoped to just its own changes
  3. Autocommit (no atomic()) — default — every query commits immediately on its own

Command

python
@transaction.atomic
def transfer(from_id, to_id, amount):
    ...

# or, scoped to part of a function:
def view(request):
    do_stuff()             # autocommit — not part of any transaction
    with transaction.atomic():
        do_more_stuff()     # atomic

Django defaults to autocommit — atomic() (decorator or context manager) is what groups statements into one real transaction. A NESTED atomic() block is a savepoint, not a separate transaction: its own rollback stays scoped to it, but the OUTERMOST block is the true commit boundary, and its rollback undoes everything, inner blocks included.

2.The broken-transaction trap, and retrying on conflict

Catching a database error (IntegrityError, an isolation-level serialization failure, a deadlock) INSIDE an atomic() block does NOT recover the transaction — the database considers it broken from that point on, and Django raises TransactionManagementError on any further query attempted inside that same block. The fix is to catch the exception OUTSIDE the atomic() block, letting the whole block roll back cleanly, then decide whether to retry from scratch.

Key features

  • try/except INSIDE an atomic() block, catching a database error and continuing to run MORE queries in that same block, raises TransactionManagementError on the next query — the transaction is unrecoverably broken once a database error occurs
  • The correct pattern: wrap the atomic() block itself in a try/except from OUTSIDE it — the whole block rolls back cleanly, and code after the except can safely start a new transaction
  • A serialization failure (Repeatable Read/Serializable isolation) or a deadlock are both legitimately RETRIABLE — they represent a real conflict with another transaction, not a data bug, and re-running the same transaction from scratch often just succeeds
  • An IntegrityError (a constraint violation) is usually NOT retriable in the same naive way — retrying the identical operation will just fail identically, since the underlying data conflict has not changed
  • Django itself does not automatically retry a failed transaction — any retry loop (for serialization failures/deadlocks specifically) has to be written explicitly by the application

Where to catch a transaction-breaking exception

Where to catch a transaction-breaking exception
LocationResult
Inside the atomic() block, then more queries in the SAME blockTransactionManagementError on the next query — worse, not better
Outside the atomic() block (wrapping the `with` statement itself)the block rolls back cleanly; code after the except runs in a fresh state
A transaction cannot recover from inside itself
caught INSIDEthe same blockcaught outsidethe blockif retriable

Transaction open

start

DB error occurs

Broken — unusable

Rolled back cleanly

end

Fresh transaction, retried

end

  • Transaction open (start)
    • → DB error occurs
  • DB error occurs
    • → Broken — unusable when caught INSIDE the same block
    • → Rolled back cleanly when caught outside the block
  • Broken — unusable
  • Rolled back cleanly (end)
    • → Fresh transaction, retried when if retriable
  • Fresh transaction, retried (end)

Command

python
for attempt in range(3):
    try:
        with transaction.atomic():
            do_the_work()
        break
    except OperationalError as e:
        if "could not serialize" not in str(e) and "deadlock detected" not in str(e):
            raise
        if attempt == 2:
            raise

Catching a database error INSIDE an atomic() block and continuing to query in that same block raises TransactionManagementError — the transaction is broken from the moment the error occurred. Catch from OUTSIDE the block instead, letting it roll back cleanly. Retry loops make sense for serialization failures/deadlocks (a timing conflict) but not for a genuine IntegrityError (a real, persistent data conflict).

3.on_commit(), durable=True, and ATOMIC_REQUESTS

transaction.on_commit(callback) registers a function that runs ONLY after the transaction actually, successfully commits — never if it rolls back, and never as part of the transaction itself. This is the correct way to send an email/trigger a background task tied to a database write, since the write inside a still-open transaction isn't durable yet. durable=True forces an atomic() block to be the OUTERMOST one — a safety check that raises immediately if it's accidentally nested inside another. ATOMIC_REQUESTS (a per-database setting) wraps every view in a transaction automatically.

Key features

  • on_commit(callback) callbacks run only after the OUTERMOST atomic() block commits successfully — never during the transaction, and never if any rollback happens
  • A callback registered inside a NESTED atomic() block still waits for the outer commit — but is discarded entirely if that specific inner block's savepoint gets rolled back, even if the outer transaction goes on to commit
  • A failure inside an on_commit() callback does NOT roll back the (already-committed) transaction — the database write is permanent regardless of what the callback does
  • durable=True forces an atomic() block to be verified as the outermost one — nesting it inside another atomic() block raises a RuntimeError immediately, rather than silently becoming a savepoint
  • ATOMIC_REQUESTS (set per-database in DATABASES) wraps every view function in a transaction automatically — @transaction.non_atomic_requests opts a specific view out

on_commit() vs a plain call inside the transaction

on_commit() vs a plain call inside the transaction
ApproachRisk
send_email() called directly inside atomic()runs even if a LATER statement in the same block causes a rollback — sends for data that was never actually saved
transaction.on_commit(send_email) inside atomic()deferred until the commit is certain — never runs if the transaction rolls back

Command

python
transaction.on_commit(lambda: send_email(order.id))
@transaction.atomic(durable=True)
def critical_operation(): ...

on_commit(callback) defers a side effect (email, webhook, background task) until the transaction is genuinely, successfully committed — never call such side effects directly inside atomic(), since the transaction might still roll back. durable=True forces a block to be the outermost transaction, raising immediately if nested — reserve it for genuinely critical, must-not-be-nested operations, not ordinary reusable functions.

Remember

  • Django defaults to autocommit — atomic() groups statements into one real transaction, all-or-nothing.
  • A nested atomic() block is a savepoint, not a separate transaction — the outermost block is the true commit boundary.
  • Catching a database error INSIDE atomic() and continuing to query breaks the transaction — catch from outside instead.
  • on_commit(callback) defers a side effect until the transaction genuinely commits — never call it directly inside atomic().

Pro tip

  • Retry loops make sense for serialization failures/deadlocks (a timing conflict) but not a genuine IntegrityError (a persistent data conflict).
  • durable=True forces a block to be the outermost transaction, raising if nested — reserve it for genuinely critical operations, not ordinary reusable helpers.

Next up

  • select_for_update()
  • Concurrency and Race Conditions
  • Migrations

Sources

  • Database transactions — docs.djangoproject.com/en/5.2/topics/db/transactions/
  • Every claim checked against Django 5.2 LTS docs · checked 2026-08-24

Spotted a mistake on this page? Tell us.