← ~/logs LOG-012

>Django Transaction Primitives

django-subatomic splits atomic() into explicit primitives so your code says what it means -- transaction, savepoint, transaction_required, and durable.

Charlie Denton and Sam Searles-Bryant’s talk at DjangoCon Europe 2026 on why transaction.atomic() conflates two different things and how django-subatomic fixes it.

The problem with atomic()

atomic() is ambiguous. The same code creates a transaction OR a savepoint depending on whether it’s already inside a transaction. You can’t tell by reading it.

with transaction.atomic():           # BEGIN
    Author.objects.create(...)
    with transaction.atomic():       # SAVEPOINT (not a new transaction)
        Book.objects.create(...)
    # RELEASE SAVEPOINT
# COMMIT

Every nested atomic() creates savepoint queries you probably don’t need. At scale (Kraken: 16M lines of Python), this adds up. And on_commit callbacks can lie — if an outer atomic() wraps your durable=True block, the commit can still be rolled back after your callback fires.

The fix: four primitives

django-subatomic splits atomic() into explicit parts:

PrimitiveWhat it does
transaction()Creates a transaction. Fails if already in one
savepoint()Creates a savepoint. Fails if NOT in a transaction
transaction_required()Asserts a transaction exists. Creates nothing
durable()Asserts code runs outside any transaction
from django_subatomic import db

with db.transaction():
    Account.objects.create(name="Alice", balance=1000)
    Account.objects.create(name="Bob", balance=500)
# COMMIT -- both accounts created atomically

The most-used primitive at Kraken is transaction_required — most code needs atomicity guarantees but shouldn’t define the transaction boundary:

@db.transaction_required
def transfer(from_acct, to_acct, amount):
    """Must be called inside a transaction. Won't create one."""
    from_acct.balance -= amount
    from_acct.save()
    to_acct.balance += amount
    to_acct.save()

durable prevents side effects inside transactions:

@db.durable
def send_welcome_email(email):
    """Guaranteed to run outside any transaction."""
    EmailService.send(email)
# If called inside a transaction, raises immediately

And run_after_commit replaces on_commit but raises if no transaction is open — no silent immediate execution:

with db.transaction():
    user = User.objects.create(username="alice")
    db.run_after_commit(partial(send_welcome_email, user.email))
# Email sends only after successful COMMIT

Key takeaways

  • atomic() was a huge improvement but hides whether you’re getting a transaction or a savepoint
  • Most code needs transaction_required, not atomic() — you want guarantees without creating boundaries everywhere
  • Unnecessary savepoints cost real queries — at scale, the extra SQL adds up
  • django-subatomic works alongside atomic(), no big-bang rewrite needed
  • Production-tested across 100+ environments at Kraken Tech

Slides | Experiment code