Database Transactions

Keep multi-step writes all-or-nothing with atomic(), and understand select_for_update and on_commit.

By default Django runs in autocommit mode: every write lands immediately. That is fine until you have an operation that must happen as a unit — debit one account, credit another. If the second write fails, the first must be undone. That is what transaction.atomic() guarantees.

atomic() as a block or a decorator

from django.db import transaction

with transaction.atomic():
    account.balance = F('balance') - amount
    account.save()
    Ledger.objects.create(account=account, delta=-amount)
# If ANY line raises, the whole block rolls back.

Three things every senior needs to know:

  • Nested atomics use savepoints. An inner block can roll back without killing the outer transaction — handy for ‘try this optional step’ logic.
  • Never swallow exceptions inside atomic(). Catching an IntegrityError and continuing inside the block leaves the transaction ‘broken’ and Django raises TransactionManagementError. Catch it outside the block.
  • Side effects belong in on_commit. Sending an email or firing a Celery task inside a transaction is a bug: if the transaction rolls back, you already sent the email. Defer it until the commit actually succeeds.

Example

Example · python
from django.db import transaction, IntegrityError
from django.db.models import F


def transfer_funds(sender_id, recipient_id, amount):
    """Move money atomically, locking both rows to prevent lost updates."""
    with transaction.atomic():
        # select_for_update locks the rows until the transaction ends,
        # so two concurrent transfers can't both read a stale balance.
        accounts = (
            Account.objects
            .select_for_update()
            .filter(pk__in=[sender_id, recipient_id])
        )
        by_id = {a.pk: a for a in accounts}
        sender, recipient = by_id[sender_id], by_id[recipient_id]

        if sender.balance < amount:
            raise ValueError('Insufficient funds')  # rolls the block back

        Account.objects.filter(pk=sender_id).update(balance=F('balance') - amount)
        Account.objects.filter(pk=recipient_id).update(balance=F('balance') + amount)
        Ledger.objects.create(sender_id=sender_id, recipient_id=recipient_id, amount=amount)

        # Fire the receipt email ONLY if the commit actually lands.
        transaction.on_commit(
            lambda: send_receipt_email.delay(sender_id, recipient_id, amount)
        )


# Retry wrapper for the rare serialization-failure deadlock:
def transfer_with_retry(*args, retries=3):
    for attempt in range(retries):
        try:
            return transfer_funds(*args)
        except IntegrityError:
            if attempt == retries - 1:
                raise
            continue   # brief backoff omitted for brevity

When to use it

  • A developer wraps a payment processing view in atomic() so a failed charge doesn't leave a half-created order.
  • A team uses transaction.on_commit() to trigger an email only after the database transaction successfully commits.
  • A developer uses select_for_update() to lock a row during a concurrent stock reservation to prevent overselling.

More examples

Atomic transaction decorator

Wraps both balance updates in a single atomic transaction so both succeed or both roll back.

Example · python
from django.db import transaction

@transaction.atomic
def transfer_funds(from_account, to_account, amount):
    from_account.balance -= amount
    from_account.save()
    to_account.balance += amount
    to_account.save()

on_commit hook for side effects

Schedules the confirmation email Celery task only after the order transaction commits successfully.

Example · python
from django.db import transaction

def create_order(user, cart):
    with transaction.atomic():
        order = Order.objects.create(user=user)
        order.items.set(cart.items.all())
        transaction.on_commit(
            lambda: send_confirmation_email.delay(order.id)
        )

select_for_update to prevent race

Locks the event row with SELECT FOR UPDATE so concurrent reservations don't exceed available seats.

Example · python
from django.db import transaction

@transaction.atomic
def reserve_seat(event_id, user):
    event = Event.objects.select_for_update().get(pk=event_id)
    if event.seats_available > 0:
        event.seats_available -= 1
        event.save()
        Booking.objects.create(event=event, user=user)

Discussion

  • Be the first to comment on this lesson.