Idempotency Explained Simply

Idempotency Explained Simply

Welcome to Architecture — a series about the backend concepts you nod along to in standups, interviews, and docs, but couldn’t actually build correctly right now without Googling. Each one sounds simple. Each one is genuinely hard to get right. We’re going to break them in plain English.

We start with the one that shows up everywhere and is explained well almost nowhere: idempotency.

What is Idemmpotency?

Idempotency is the property of an operation where applying it multiple times yields the same final result as applying it just once.

In computer science, this means that even if a request is repeated (e.g., due to a lost network connection), it will not cause unintended side effects or duplicate actions.

If that word makes your eyes glaze, good. By the end of this you’ll be able to explain it at a dinner party, and implement it without shipping the bug that 90% of implementations ship.

The one-sentence version

Same Effect vs Repeated Effect Same Effect vs Repeated Effect

An operation is idempotent if doing it many times has the same effect as doing it once.

That’s it. Flipping a light switch to on is idempotent — flip it on five times, the light’s just on. Pressing an elevator button is idempotent — mash it ten times, the elevator still comes once. Adding $5 to your bank balance is not idempotent — do it five times and you’re $25 richer.

Technically correct, and completely useless until you see where it bites. So let’s go to the place it actually hurts: money.

The bug, in a story

Timeout Does Not Mean Failure Timeout Does Not Mean Failure

A user taps Pay Now. Their phone sends the request to your server. The payment goes through, the charge succeeds — and then, right before your server’s “success!” response reaches the phone, their train goes into a tunnel and the connection drops.

The phone never heard back. So it does the sensible thing: it retries. It sends the exact same payment request again.

Your server has no idea this is a repeat. It sees a fresh, valid payment request and does its job: it charges the card again.

The customer is now charged twice. They have one coffee and two transactions. You have an angry email and a support ticket, and you didn’t write a single line of “buggy” code — every part of your system did exactly what it was told.

This is the core problem: the network can’t tell you whether a request failed before or after it was processed. A timeout doesn’t mean “it didn’t happen.” It means “I don’t know if it happened.” And when you don’t know, you retry — and retrying is how you double-charge people.

Why “just check if it already happened” doesn’t work

Race Condition in Naive Idempotency Race Condition in Naive Idempotency

The first fix everyone reaches for sounds airtight:

“Before charging, check if we already charged this person for this. If yes, skip it.”

In code, that instinct looks like this:

def charge(user, amount, order_id):
    if Payment.objects.filter(order_id=order_id).exists():  # already done?
        return "already charged"
    # ... otherwise, charge the card and save the payment
    process_card(user, amount)
    Payment.objects.create(order_id=order_id, amount=amount)

Read it and it looks correct. It is not. It has a gap you can drive a truck through.

Picture the two retries arriving at almost the same time — which is exactly what happens when a phone fires a request, times out, and retries while the first one is still in flight. Both requests run that exists() check at the same moment. Neither has saved a payment yet, so both see "nope, not charged," and both proceed to charge the card.

This is a race condition (we’ll dig into those properly later in the series). The check and the action are two separate steps, and another request can sneak in between them. A simple “does it exist?” boolean cannot save you, because at the moment both requests look, the answer is honestly “no” for both.

What actually fixes it: the idempotency key

Client Key Identifies One Intent Client Key Identifies One Intent

Here’s the real pattern, and it’s the one Stripe built their payments API around.

Step 1 — the client generates a unique key for the operation. Not the server. The client, before it sends anything, creates a random unique ID — a UUID — that stands for “this one specific payment attempt.” It sends that key along with the request, usually in a header:

Idempotency-Key: 7f3c9c2e-4a1b-4c6d-8e9f-1a2b3c4d5e6f

The crucial part: if the request times out and the client retries, it sends the same key. The key identifies the intent (“this payment”), not the individual network attempt. Ten retries, one key.

Step 2 — the server uses the key to recognize repeats. The first time the server sees a key, it does the work and stores the result against that key. Every later request with the same key gets the stored result handed back — no reprocessing, no second charge.

Stripe does exactly this: it saves the status code and body of the first response for a given key, and returns that same saved response on every retry — even if the original was a 500 error. The retry replays the original outcome instead of creating a new one.

The part 90% of implementations get wrong

This is where it stops being trivia and starts being engineering. There are two subtle things almost everyone botches.

1. Don’t check-then-insert. Insert first and let the database referee.

Database as the Referee Database as the Referee

We just saw that “check, then act” has a race window. The fix is to flip the order. Don’t ask “does this key exist?” Instead, immediately try to insert the key into a table that has a UNIQUE constraint on it:

from django.db import IntegrityError, transaction

def charge(user, amount, idempotency_key):
    try:
        with transaction.atomic():
            # Try to claim the key. The UNIQUE constraint is the referee.
            IdempotencyRecord.objects.create(
                key=idempotency_key,
                user=user,
                status="processing",
            )
    except IntegrityError:
        # Someone already claimed this key — this is a retry.
        record = IdempotencyRecord.objects.get(key=idempotency_key)
        return record.stored_response   # replay the original result

    # We won the claim. We are the ONLY request that will charge.
    result = process_card(user, amount)

    record = IdempotencyRecord.objects.get(key=idempotency_key)
    record.status = "completed"
    record.stored_response = result
    record.save()
    return result

Why this works where the boolean check failed: a database UNIQUE constraint is atomic. When two retries try to insert the same key at the same instant, the database guarantees that exactly one succeeds. The other gets an IntegrityError — and that error is your signal: "this is a duplicate, go fetch and replay the saved response." You're no longer doing the refereeing in your own code (where the race lives); you're letting the database do it, because that's the one place that can do it correctly under concurrency.

The mental upgrade: stop asking “has this happened?” and start saying “I’m claiming this, reject me if someone already did.” The first is a question with a stale answer. The second is atomic.

2. Store the result, not just the key.

Replay the Original Result Replay the Original Result

The other classic miss: people record that a key was used but forget to save what the result was. Then the retry arrives, the server goes “yep, seen that key, I’ll skip the work” — and returns… nothing. Or an error. The client wanted the original answer (the receipt, the confirmation, the charge ID), and you threw it away.

So the record has to hold the actual response. The retry doesn’t just get blocked — it gets handed the exact same successful result the first request produced. That’s what makes it look, to the client, as if their retry “just worked.”

A few things worth knowing (so you don’t relearn them the hard way)

  • Scope the key per user/account, not globally. Two different customers might generate the same key string by chance. Make the uniqueness (user_id, key), so one user's key can never collide with another's.
  • Keys should expire. Stripe’s keys are valid for 24 hours, which is long enough to cover any sane retry window and short enough that you’re not storing them forever. Pick a window that comfortably outlasts how long your clients retry.
  • Not every error should be retried. A declined card or invalid input will never succeed on retry — retrying it just wastes everyone’s time. Retries are for network and server failures (the “I don’t know if it worked” cases), not for “the answer is definitely no.”
  • GET requests are already idempotent; POST is not. Reading data twice is harmless. Creating things is where you need the key.

Why this is so much harder than it looks

The reason idempotency is “mentioned everywhere, explained nowhere” is that the one-sentence definition hides three distinct hard problems inside a single innocent phrase: “check whether we’ve seen this before.” That tiny clause is secretly asking you to handle concurrency (two retries at once), partial failure (the server died halfway), and result storage (what do we hand back?) — none of which the definition warns you about.

That’s the whole theme of this series. These concepts feel simple because the idea is simple. The difficulty is never in the idea — it’s in the gap between the clean definition and the messy, concurrent, failure-prone reality where you have to implement it. Idempotency is the perfect first example: a one-line concept, a one-line bug, and a fix that quietly depends on understanding your database’s deepest guarantee.

You don’t need to be an expert to get this right. You just need to know where the gap is. Now you do.

Production Idempotency Rules Production Idempotency Rules

The 30-second takeaway

  • Idempotent = doing it many times has the same effect as doing it once.
  • It matters because a network timeout can’t tell you if the request succeeded, so clients retry — and naive retries double-charge, double-create, double-everything.
  • A simple “check if it already happened, then do it” fails under concurrent retries — that’s a race condition.
  • The fix: the client sends a unique idempotency key; the server tries to INSERT it under a UNIQUE constraint (insert-first, don't check-first) and lets the database guarantee only one wins.
  • Store the original response against the key, so retries replay the same result instead of getting nothing.
  • Scope keys per user, expire them, and only retry network/server errors — not declines or bad input.

Next in the series: Why Your Queue Processes the Same Job Twice — at-least-once vs exactly-once delivery, why “exactly-once” is a myth vendors sell you, and why none of that matters once your worker is idempotent (yes, this is the payoff for today’s article).

SUBSCRIBE FOR NEW ARTICLES

@
comments powered by Disqus