Transactions and select_for_update — atomicity and race conditions
Every Django request runs in autocommit mode by default — each database write commits immediately, on its own. atomic() is what groups several writes into one all-or-nothing unit, and select_for_update is what stops two concurrent requests from racing on the same row.
3 min read
The default: autocommit, one statement at a time
def transfer_funds(from_account, to_account, amount):
from_account.balance -= amount
from_account.save() # COMMITTED to the database immediately
# if a crash or exception happens on the line below, the money has
# already left from_account — but never arrived at to_account
to_account.balance += amount
to_account.save()By default, Django runs in autocommit mode — each .save() (or .delete(), or .update()) commits to the database as its own independent unit, immediately. That's fine when writes are genuinely independent, but it's a real bug here: if the process crashes, raises an exception, or the database connection drops between the two .save() calls, money has left one account and never reached the other — the database is left in a state that should never have been reachable.
atomic(): grouping several writes into one all-or-nothing unit
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()
# if ANYTHING raises inside this function, EVERY write above is rolled back —
# the database ends up as if none of it had ever run@transaction.atomic (usable as a decorator or a with transaction.atomic(): block) wraps every database write inside it into a single transaction — either all of them are committed, or, if any exception propagates out of the block, all of them are rolled back, leaving the database exactly as it was before the block started. This is the fix for the funds-transfer bug above: a crash between the two .save() calls now rolls back the first one too, instead of leaving the transfer half-done.
Atomic blocks can nest — inner blocks become savepoints
@transaction.atomic
def process_order(order):
order.status = "processing"
order.save()
try:
with transaction.atomic(): # a SAVEPOINT, not a fully separate transaction
charge_payment(order) # if this raises, only THIS inner block rolls back
except PaymentError:
order.status = "payment_failed"
order.save() # the outer transaction still commits normallyA nested atomic() block doesn't start an independent transaction — it creates a savepoint within the outer transaction, so a failure inside the nested block can be caught and rolled back on its own, without forcing the entire outer transaction to abort. This is what makes it possible to attempt something that might fail (like calling an external payment API) inside a larger atomic operation, and handle that specific failure gracefully while still committing everything else.
select_for_update(): locking a row against concurrent writes
@transaction.atomic
def redeem_ticket(ticket_id):
ticket = Ticket.objects.select_for_update().get(id=ticket_id)
if ticket.redeemed:
raise TicketAlreadyRedeemed()
ticket.redeemed = True
ticket.save()atomic() alone guarantees a set of writes is all-or-nothing — it does not stop two concurrent requests from both reading the same row's stale state before either one writes. Without a lock, two simultaneous "redeem this ticket" requests can both read redeemed=False, both pass the check, and both mark it redeemed — the same ticket gets redeemed twice. select_for_update() locks the matching row(s) in the database for the duration of the transaction: a second concurrent request calling select_for_update() on the same row blocks until the first transaction commits or rolls back, which is exactly what turns "read, check, then write" into a genuinely safe sequence under concurrency. It only has an effect inside an atomic() block — the row lock is released when that transaction ends.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What does Django's default autocommit mode mean for a sequence of two .save() calls?
2. What happens to the writes inside an @transaction.atomic block if an exception is raised partway through?
3. What does a nested atomic() block actually create, inside an already-atomic outer block?
4. Why does atomic() alone NOT prevent two concurrent requests from both redeeming the same ticket?