A reader predicts "the transfer is a single ACID transaction, so retrying after a timed-out COMMIT is safe." Run it on sqlite3 and the retry moves $22, not $11 — atomicity is not idempotence. Add a requests table with UNIQUE(request_id) and pass a client-generated id end-to-end, and the retry hits the constraint, rolls back, and the transfer happens exactly once.
A reader reasons: "the transfer is a single ACID transaction, so retrying it after a timed-out COMMIT is safe." Run it on sqlite3 and watch the retry move $22 instead of $11. Atomicity guarantees each transaction is all-or-nothing — but it says nothing about running that transaction twice. When the client never hears back from the first COMMIT and retries, the second, equally-atomic transaction moves the money again: payer 100→78, payee 0→22. Then add a requests table with a UNIQUE(request_id) constraint and pass a client-generated id end-to-end (DDIA Example 13-2): the retry's INSERT hits the uniqueness constraint, the whole transaction rolls back, and the transfer happens exactly once — payer 100→89, payee 0→11. Same transfer, same retry; the only difference is whether the operation itself can recognize a duplicate.
Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 13 — The Future of Data Systems (§"The end-to-end argument for databases", §"Duplicate suppression", §"Uniquely identifying requests", Examples 13-1 and 13-2):
Just because a transaction is atomic doesn't mean it is idempotent [...] To make the operation idempotent, we can attach a unique identifier to the request and make sure it is only executed once.
The book uses a bank transfer to separate two properties that sound interchangeable: atomicity (each run is all-or-nothing) and idempotence (running it twice equals running it once). TCP retransmission and the database's own transaction handling both suppress duplicates — but only within a single connection. The moment a request crosses that boundary and is retried, the duplicate is a fresh, legitimate transaction. Only an identifier the operation checks can close the gap. Here you make the double-charge happen, then close it.
The surprise lives in the gap between "atomic" and "idempotent." Each line notes where it shows up.
transfer(), which always move $0 or the full $11 together.transfer() call moving another $11.transfer() call standing in for that retry.requests table the operation checks, not in the transport.UNIQUE(request_id) and the caught sqlite3.IntegrityError.sqlite3) — no Docker, no deps. No real network: the lost COMMIT ack and the retry are simulated by calling transfer() a second time in one process. Fixed workload: transfer $11, payer seeded $100, payee $0; request id req-6f1a2b3c-0001 (fixed, not random). Balances are whole dollars (integers).
Same transfer, same retry — only whether the operation can spot a duplicate differs.
transfer($11) runs two UPDATEs (payer −11, payee +11) and COMMITs, as one atomic transaction. Atomicity means each run is clean: all-or-nothing. But a retry is a second run, and two clean runs move $22. Nothing in the transaction remembers it already happened.transfer_once(id, $11) first INSERTs the client's request_id into a requests table with a UNIQUE constraint, inside the same transaction as the money move. The retry carries the same id; its INSERT fails the constraint (sqlite3.IntegrityError) before any money moves, the transaction rolls back, and the call is a no-op. The id, checked by the operation itself, is what makes the retry idempotent.Both live in one script, code/retry_double_charge.py, each against a fresh in-memory database seeded payer $100 / payee $0.
cd study/ddia/ch13/code
python3 retry_double_charge.py
Do not read the output yet — make each prediction first.
Predict. transfer($11) runs UPDATE payer −11; UPDATE payee +11; COMMIT against a ledger seeded payer $100, payee $0. After one call, what are the two balances?
Payer $89, payee $11 — no surprise. One clean, all-or-nothing transfer. The surprise is what a retry does to it.
Predict. The client never received the COMMIT ack, so it retries the identical transfer($11). The reader's claim is "it's one ACID transaction, so retrying is safe." After the retry, what are the balances — and how much moved?
Payer $78, payee $22 — $22 moved, not $11. The claim is wrong. The second transaction was just as atomic as the first, and atomicity has nothing to say about running it twice. TCP and the DB connection do suppress duplicates — but only within one connection; a client-level retry is a brand-new, fully valid transaction. Nothing errored. The ledger is simply $11 short.
Predict. New ledger (payer $100, payee $0), plus a requests table with UNIQUE(request_id). transfer_once(id, $11) INSERTs the id and moves the money in one transaction. On the first attempt with id req-6f1a2b3c-0001, what does it return and what are the balances?
applied=True, payer $89, payee $11 — the id was new, so the INSERT succeeded and the transfer went through exactly like Scenario 1's first call.
Predict. The retry carries the same id req-6f1a2b3c-0001. Its first act is to INSERT that id — which already exists under a UNIQUE constraint. What happens to the INSERT, and what are the final balances?
applied=False, balances unchanged at $89 / $11 — exactly $11 moved. The duplicate INSERT raised sqlite3.IntegrityError before any UPDATE took effect; the transaction rolled back and the call became a no-op. The operation recognized its own retry and skipped it.
sqlite3.IntegrityError, returns False, and leaves the balances unchanged — exactly once.The client cannot distinguish "COMMIT succeeded but the ack was lost in the network" from "COMMIT never happened." Those two states look identical from the outside — silence — yet demand opposite actions (do nothing vs. retry). A client that wants the transfer to eventually happen has no choice but to retry, which means the server must be prepared to receive the same request twice. This is at-least-once delivery, and it is the honest default for anything crossing a network.
Atomicity doesn't help, because it is a property of a single transaction, not of the pair. Each run of transfer() is flawless — both UPDATEs commit together, never half. But "all-or-nothing, twice" is still two transfers. The bug isn't in the transaction; it's that the transaction has no memory of having run. TCP's sequence numbers and the database's own duplicate handling would catch a retransmitted packet or a replayed statement on the same connection — but the retry arrives on a new connection, as a request the lower layers have every reason to treat as new. Duplicate suppression at the transport layer stops at the connection boundary; the money move lives above it.
That is exactly the end-to-end argument: a correctness property can only be guaranteed by the endpoints that fully understand it. Here the property is "apply this transfer at most once," and the only component that knows what "this transfer" means is the operation itself. So the fix pushes an identifier all the way down to it. The client generates a request id once, reuses it across every retry, and the server records it under a uniqueness constraint in the same transaction as the money move. Now the second attempt's INSERT collides, the transaction aborts before any balance changes, and atomicity — the very property that couldn't prevent the double-charge — is what guarantees the rolled-back retry leaves nothing behind. The id turns a non-idempotent operation into an idempotent one by giving it the one thing it lacked: a way to recognize itself.
SET balance = 89 rather than balance = balance - 11 — is safe to replay, because the second application writes the same value; no id required. Second, a call that is never retried can't double-apply, but on a real network you can't promise that. The double-charge needs both halves: an effect that compounds when repeated (a relative debit) and a delivery model that repeats it (at-least-once). Remove either and the request id buys you nothing.
Idempotency-Key header is this exact construction in production: the client sends a UUID, Stripe stores it, and a replay returns the original result instead of charging again. Trace how their docs describe the key's lifetime and scope, and compare it to the UNIQUE(request_id) row here.UNIQUE constraint works because the id and the money live in one database. When the debit and credit are on different shards, a single constraint can't span them — you need a two-phase construction (or a transactional outbox) to get the same at-most-once guarantee. That is a sibling Ch. 13 exercise.Sources: DDIA 2e, Ch. 13, §"The end-to-end argument for databases", §"Duplicate suppression", §"Uniquely identifying requests" (Examples 13-1, 13-2) · Saltzer, Reed & Clark, "End-to-End Arguments in System Design" (1984).