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
IntegrityErrorand continuing inside the block leaves the transaction ‘broken’ and Django raisesTransactionManagementError. 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
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 brevityWhen 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.
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.
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.
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