Skip to the content
Software Made Clear Diagrams that show the mechanism About

Idempotency keys and safe retries

Give every retry a key, store its result in the same transaction as the effect, and stop worrying whether a lost response means the work happened twice.

6 min read Updated September 30, 2026 Last verified June 19, 2026
ANSWER

A retry is not a new request, but your API cannot tell the difference unless the client says so. Give it a key, store the result against that key in the same transaction as the effect, and return the stored result the second time.

IN PLAIN TERMS

You collect a takeaway with a numbered ticket. Show the same number again and you get the order you already placed, not a second one. Without a number, every time you ask looks like a brand new order and the kitchen starts cooking again.

A phone loses signal after a request leaves but before the response returns. The client cannot infer whether the effect committed. Retrying is safe only when the operation’s contract handles repeated attempts; another option is querying the operation’s state. The distributed-systems path works through that uncertainty.

WHAT THE CLIENT SENTeach attempt carries Idempotency-Key: k_9f2cNothing sent yet — press “Send the order”.WHAT THE WAREHOUSE RECEIVEDnothing receivedWHAT THE CLIENT SENTeach attempt carries Idempotency-Key:k_9f2cNothing sent yet — press “Send theorder”.WHAT THE WAREHOUSE RECEIVEDnothing received
0 attempts · 0 orders
In this stored-result model, matching requests with the same retained key reuse one order. Attempts go up; the order count does not.

What the key has to promise#

The client generates the key, once, before the first attempt, and reuses it for every retry of that intention. That is the part teams get wrong: a key generated per HTTP call is not an idempotency key, it is a request id, and it protects nothing.

Scope the key to the authenticated owner and operation so one caller cannot replay another caller’s result. Store a canonical representation or digest of the parameters that define the intention, and reject reuse with different parameters. A byte-level body hash can reject harmless encoding changes; document how equivalence is decided. A 422 response is one possible policy, not a universal requirement.

The window matters too. Choose a retention policy. If a key is pruned and no other invariant prevents repetition, a retry after that window can perform the work again. Twenty-four hours is a common answer; whatever you choose, it belongs in the documentation, because it is part of the contract.

Where the record has to live#

In the same database, in the same transaction, as the effect it is protecting. Not in Redis beside the database, not in a middleware that writes after the handler returns — because every one of those introduces a moment where the effect exists and the key does not, and that moment is exactly the one the retry lands in. It is a transaction-boundary question before it is an idempotency question: state and the record of that state change commit together, or neither does. That ordering is why the architecture path reaches boundaries before it reaches this article.

handler.py
# The claim and the effect commit together, or not at all.
# PostgreSQL READ COMMITTED; UNIQUE(key); no concurrent retention deletion.
# key is scoped to the authenticated owner and operation, not client text alone.
# request_hash is a canonical digest of the parameters that define this intent.
with conn.transaction():

    # RETURNING yields a row only if THIS statement did the insert.
    # One row: this request owns the work. No row: someone else does.
    claim = conn.execute(
        """INSERT INTO idempotency (key, request_hash, status)
           VALUES (%s, %s, 'in_flight')
           ON CONFLICT (key) DO NOTHING
           RETURNING key""",
        (key, request_hash),
    ).fetchone()

    # The branch is HERE, in the caller — not in the SQL. An empty RETURNING
    # is a signal this code has to act on; the database will happily run
    # whatever statement comes next, for the loser as readily as the winner.
    if claim is None:
        previous_hash, status, response = conn.execute(
            "SELECT request_hash, status, response FROM idempotency WHERE key = %s", (key,)
        ).fetchone()
        if previous_hash != request_hash:
            raise ValueError("same scoped key, different intention")
        return status, response  # caller translates the saved outcome to HTTP

    # Only the request that won the claim reaches these two statements.
    # (Not folded into the INSERT above with WITH: in Postgres a data-modifying
    # CTE and the main query share one snapshot, so an UPDATE in the same
    # statement would never see the row the INSERT just claimed.)
    conn.execute("INSERT INTO orders (customer, item_id) VALUES (%s, %s)", (4711, 88))
    conn.execute(
        "UPDATE idempotency SET status = 'succeeded', response = %s WHERE key = %s",
        ("{…}", key),
    )

Illustrative Psycopg-style code, not a complete HTTP handler or an executed PostgreSQL test. It assumes PostgreSQL Read Committed, a unique scoped key and no pruning during replay. A competing insert can wait for the first transaction: its uncommitted in-flight row is not a public progress record. After a committed conflict, the next SELECT has a new snapshot and reads the outcome. The loser must branch before doing work, and must check the stored request digest. Bound lock waiting and handle transaction failures. This model includes only effects inside this database transaction; the figure separately models sequential retries, not transaction locking.

The four ways it still breaks#

01

The key is generated per attempt

Then every retry is a new intention as far as the server is concerned, and you have built an audit trail of duplicate work rather than a defence against it.

02

The in-flight state is missing

Two attempts can both check and see no record before either writes. A unique claim plus a branch that honours the result closes that race. A separate committed in-progress design needs its own ownership, recovery and timeout rules; it is not the single-transaction example above.

03

Failures are cached as results

Define which failures are replayed and which can safely be tried again. Stripe, for example, replays saved results including some server errors. Release a claim only when you know its protected effect did not commit; an uncertain external payment is not made safe by deleting a key.

04

The side effects are outside the transaction

The write is idempotent, and then the handler also sends an email. The second attempt replays the stored response and sends a second email, because the email was never part of the record.

What to return on a replay#

Publish the replay contract. Stripe saves the first executed request’s status and body, including error results. EC2 can return updated creation state without repeating the action. Both prevent repeated effects within their stated scope; idempotence does not require byte-identical responses in every API.

IF YOU REMEMBER ONE THING

An idempotency key is not a cache key. It is a claim on a piece of work, and it has to be written down in the same breath as the work itself.

A correctly scoped key and effect/replay contract can make retries safe within its documented window. It does not make the retry someone else’s problem, which is the usual reason a queue gets added — and most of the time the queue is the wrong answer.

Questions people also ask

6 QUESTIONS
Where should the idempotency key come from?

The client, generated once per intention, before the first attempt, and reused unchanged for every retry of that same intention. A UUID is fine. A key generated per HTTP call is not an idempotency key — it is a request id, and it protects nothing.

How long should I keep keys?

Long enough to outlast the slowest client's own retry budget, which is the number to ask for rather than guess at. A mobile client that retries over 48 hours of patchy signal is not served by a 24-hour window, and the mismatch shows up as duplicate work nobody can reproduce. Publish the window, and treat shortening it as a breaking change.

Can I store the key in Redis instead of the database?

Not safely, if the effect it protects lives in the database. The key and the effect have to commit together; two stores means a window where the effect exists and the key does not, and that is precisely the window a retry lands in.

What should the API return on a replayed request?

Follow the API's documented policy. Some replay the saved status and body; others return the same operation with updated state. For concurrent requests, define bounded waiting or an explicit in-progress response. Do not claim success before the effect is known.

What if the same key arrives with a different body?

Reject it. Store comparable canonical parameters or a digest beside the scoped key. Compare them before replay so the same key cannot silently substitute a different intention. That is the failure mode with no log line.

Does idempotency remove the need for retries?

It does the opposite: it makes retries safe, so clients can and should retry. The harder case is a client you do not control — a third-party webhook sender that retries on its own schedule and sends no key at all. There you have to derive one from something stable in the payload, usually the provider's own event id, and that decision belongs in writing next to the endpoint.

The library Browse all articles