Idempotent operations
Making a repeated call harmless inside a single process too.
01The intuition
The problem it solves
A shop's checkout button charges the card. The network hiccups after the request is sent, the page shows an error, and the customer clicks again. The first version charges twice, because to the server each click is a new order.
class Checkout:
def __init__(self) -> None:
self.charges: list[tuple[str, int]] = []
def pay(self, customer: str, amount: int) -> str:
self.charges.append((customer, amount)) # every call charges
return f"charge-{len(self.charges)}"
Retries are everywhere: a user double-clicks, a client library retries a timeout, a queue redelivers a message, a crashed process restarts and replays its last step. If repeating a call changes the outcome, every one of those becomes a bug. An operation is idempotent when doing it twice has the same effect as doing it once, and the trick is to make the important operations idempotent on purpose.
Build it one piece at a time
Step one: the caller sends a key that identifies the attempt, not the click. The client generates it once per intended payment and reuses it on every retry. The server remembers which keys it has seen and returns the original result for a repeat instead of acting again.
class Checkout:
def __init__(self) -> None:
self.charges: list[tuple[str, int]] = []
self.seen: dict[str, str] = {} # idempotency key -> charge id
def pay(self, customer: str, amount: int, key: str) -> str:
if key in self.seen:
return self.seen[key] # replay: same answer, no new charge
self.charges.append((customer, amount))
charge_id = f"charge-{len(self.charges)}"
self.seen[key] = charge_id
return charge_id
Step two: the check and the act must be one step, or two concurrent retries both pass the check. Put them under a lock, and record the key before doing the side effect so a crash in between leaves a record to reconcile rather than a silent double charge.
import threading
class Checkout(Checkout): # continued
_lock = threading.Lock()
def pay(self, customer: str, amount: int, key: str) -> str:
with self._lock:
if key in self.seen:
return self.seen[key]
self.seen[key] = "pending" # claim the key first
charge_id = self._charge(customer, amount) # slow part outside the lock
with self._lock:
self.seen[key] = charge_id
return charge_id
def _charge(self, customer: str, amount: int) -> str:
self.charges.append((customer, amount))
return f"charge-{len(self.charges)}"
Step three: prefer operations that are idempotent by their shape, so no key is needed. "Set balance to 50" can be repeated; "add 10 to balance" cannot. "Mark order 17 as shipped" can be repeated; "ship the next order" cannot. Designing state changes as assignments to a named target removes a whole class of retry bugs.
class Order:
def __init__(self) -> None:
self.status = "new"
def mark_shipped(self) -> None:
self.status = "shipped" # safe to call twice
class Counter:
def __init__(self) -> None:
self.value = 0
self.applied: set[str] = set()
def add(self, delta: int, op_id: str) -> None:
if op_id in self.applied: # an increment needs a key
return
self.applied.add(op_id)
self.value += delta
Three calls with the same key, one of them racing:
| Call | seen before | Action | Returned |
|---|---|---|---|
| pay(k1), first click | absent | claim k1, charge | charge-1 |
| pay(k1), retry during charge | pending | no charge | pending (caller polls or waits) |
| pay(k1), retry after | charge-1 | no charge | charge-1 |
| pay(k2), new order | absent | claim k2, charge | charge-2 |
Why it works
The invariant is that each idempotency key produces at most one side effect, ever. Claiming the key before acting, under a lock, makes that true even with concurrent retries, and storing the result with the key makes the repeat return the same answer, which is what the retrying client needs. Operations shaped as assignments carry the invariant in their meaning, with no bookkeeping at all.
Where it breaks: the key store grows forever unless keys expire, so keep them for as long as a retry is plausible (hours or days) and no longer. A key reused for a different payload is a client bug; store a hash of the request with the key and reject mismatches. And a "pending" claim left behind by a crash must be reconciled with the real side effect, which is why the slow part should itself be checkable.
without keys: expected charges per intended payment = 1 + retry rate. With keys: exactly 1
The trade-off you must say out loud
Idempotency costs a key per operation, storage for seen keys, and the discipline of claiming before acting; it buys the freedom to retry anything, which is the foundation every reliable system rests on. Say which operations are naturally idempotent, which need a key, and how long keys live. The follow-up that usually comes next is "who generates the key", and the answer is the caller, as close to the user's intent as possible, so that a retry of the same intent reuses it.
Where it shows up in the 100 Google problems
- Payment processor: idempotent charges with claimed keys and a reconciliation path for pending ones.
- Digital wallet: a transfer id that makes a replayed transfer a no-op.
- Blocking connection pool: releasing a connection twice must be harmless, an idempotent release.
- Build your own Future: completing a future is first-write-wins; callbacks run exactly once.
- Notification service: a message id per notification so a retried send does not ping the user twice.