Atomicity
"Can an operation partially succeed?"
Applies To
payments, orders, enrollments, inventory, database writes, queues with side effects
Why It Happens
A first-principles walkthrough of why an operation must be all-or-nothing, what a commit actually does underneath (WAL, fsync, visibility), why a crash between a Stripe charge and an INSERT leaves money taken with no record, and how a pending row plus transactional outbox restores atomicity across services. Grounded in Postgres transactions and the dual-write problem.
How It Works Underneath
Every database write is not a single action but a sequence: the client sends a query, the engine appends to the Write-Ahead Log (WAL), fsyncs to disk, updates the heap page, and only then reports success. A transaction groups several of these sequences into one all-or-nothing gate. BEGIN opens the gate, COMMIT closes it and makes all WAL entries visible at once, ROLLBACK discards them. Without that gate, a process crash between two INSERTs leaves the first visible and the second absent — a partial commit.
Think of a bank transfer as two letters that must arrive together: debit and credit. If the courier crashes after delivering only the debit, money vanishes. The transaction is the envelope that says “deliver both or deliver neither.” Distributed systems cannot envelope two services in one gate, so we use the transactional outbox: persist the order and an outbox event in the same local transaction, then a relay publishes the event. The outbox is the envelope.
Crash between charge and UPDATE? Row stays
pending, reconciler re-checks provider as source of truth.
Common Misconceptions
“I wrapped it in a try/catch, so it’s atomic.” A catch does not undo a committed row. Only a transaction does. Another: “A single HTTP request is atomic.” A request that does two writes is two chances to crash, not one.
How to Detect It
Search for any handler that does more than one db.execute without an explicit BEGIN/transaction(). In code review, ask: “If the process dies between line N and N+1, what row is visible?” The answer must be “none.”
Cataloged Failure Modes
partial_commitcrash_between_writesorphaned_side_effect
Code Comparison
# NAIVE: Process crash leaves customer charged without order record
async def process_checkout(user_id: str, amount: float):
charge = await stripe.charge(amount=amount)
await db.execute("INSERT INTO orders (user_id, status) VALUES (%s, 'paid')", user_id)
return {"order_id": charge.id}
# IMPROVED: Transactional outbox & pending state before charge
async def process_checkout(user_id: str, amount: float, idempotency_key: str):
async with db.transaction() as tx:
order = await tx.execute(
"INSERT INTO orders (id, user_id, amount, status, idempotency_key) "
"VALUES (%s, %s, %s, 'pending', %s) RETURNING id",
uuid4(), user_id, amount, idempotency_key
)
try:
charge = await stripe.charge(amount=amount, idempotency_key=idempotency_key)
await db.execute("UPDATE orders SET status = 'confirmed', charge_id = %s WHERE id = %s", charge.id, order.id)
except Exception as e:
logger.error(f"Checkout error for order {order.id}: {e}")
raise