Idempotency explained: making it safe to try again
Retries are not optional in a distributed system, which makes idempotency a correctness property rather than a nicety. Keys, windows, and the difference between idempotent and merely harmless.
On this page
- What we'll cover
- Why you cannot avoid retrying
- Idempotent, or merely harmless
- Choosing the key
- The window, and what happens outside it
- Making common operations idempotent
- Where it gets genuinely hard
- Calling APIs that support it
- Testing that it actually holds
- Building it with CodeWords
- What to get right first
- Frequently asked questions
- Related reading
An operation is idempotent if doing it twice leaves the world in the same state as doing it once. That definition is easy to state and surprisingly easy to get wrong, because the interesting question is not whether the second call returns the same answer — it is whether the second call causes the same side effects.
It matters because retries are not optional. A network timeout does not tell you whether the request arrived, so any client that gives up has lost work and any client that retries may duplicate it. Idempotency is what makes the second choice safe.
What we'll cover
- Why you cannot avoid retrying
- Idempotent, or merely harmless
- Choosing the key
- The window, and what happens outside it
- Making common operations idempotent
- Where it gets genuinely hard
- Calling APIs that support it
- Testing that it actually holds
- Building it with CodeWords
- What to get right first
- Frequently asked questions
Why you cannot avoid retrying
A timeout is ambiguous. The request may have failed before arriving, arrived and failed, or succeeded with the response lost. From the caller's side these are indistinguishable.
Not retrying loses work. If you treat every timeout as a failure, some genuinely successful operations get reported as failures and some genuinely failed ones never happen.
Retrying without idempotency duplicates. Two orders, two emails, two charges.
The infrastructure retries whether you asked or not. Load balancers, client libraries, queue redelivery, and webhook senders all retry by default, so even a system with no explicit retry logic experiences duplicates.
Therefore the choice is not whether to be idempotent. It is whether to be idempotent deliberately or to discover the duplicates in production.
Idempotent, or merely harmless
A distinction worth holding, because it decides how much work you need to do.
Naturally idempotent operations produce the same state regardless of repetition. Setting a value to a specific number, deleting by identifier, marking something as read. Repeating them changes nothing.
Not idempotent operations accumulate. Incrementing a counter, appending to a list, sending a message, creating a record with a generated identifier.
Made idempotent operations are the middle case: not naturally safe, but made safe by recording that the work was done and checking before doing it again.
Read operations are trivially idempotent, which is why the discipline only bites on writes and on anything with an external effect.
"It is probably fine" is not a category. An operation that is usually harmless when repeated will eventually be repeated at the worst moment, and "probably" is how duplicate charges happen.
Choosing the key
The design decision that determines whether any of this works.
Derive the key from the request, not from the attempt. A key generated fresh on each retry defeats the entire mechanism, and this is the single most common implementation error.
Use something meaningful from the source. The order identifier, the event identifier, the message identifier — something that is the same on every attempt because it identifies the work rather than the request.
A hash of the payload works when nothing else is available, with the caveat that two genuinely distinct requests with identical content become indistinguishable.
Include enough to be unique. A customer identifier alone is not a key; a customer identifier plus a date plus an operation type might be.
Do not use a timestamp, which differs on every attempt and therefore never matches.
Store the key with the result, so a repeat can return the original outcome rather than recomputing it.
The window, and what happens outside it
Idempotency is almost always bounded in time, and the boundary matters.
Storing keys forever is impractical, so systems keep them for a window — hours, or days.
Inside the window, a repeat is detected and the original result returned.
Outside it, the same request is a new request, which means a very late retry duplicates.
Match the window to your retry policy. If a queue can redeliver for three days, a one-hour idempotency window does not protect you.
Say what the window is. An API that deduplicates without documenting for how long is one you cannot reason about.
Watch for the slow consumer. A backlog that takes longer to drain than the window means messages fall outside protection precisely when the system is under stress.
Making common operations idempotent
The practical patterns, in rough order of how often they are needed.
Creating a record: derive the primary key from the source rather than generating one, and use an insert-or-ignore, or an upsert on a unique constraint. The database enforces uniqueness rather than your code checking first, which removes the race.
Updating: set absolute values rather than deltas. "Set the total to 40" is idempotent; "add 10" is not.
Sending a message: record that it was sent, keyed on the triggering event, and check before sending. There is no way to un-send, so the check has to be before.
Incrementing a counter: record the contributing identifiers and count them, or store a set and take its size, rather than adding to a number.
Calling an external API: pass an idempotency key if it supports one; otherwise record the call before making it and reconcile afterwards.
Deleting: usually naturally idempotent, with the caveat that a second delete should succeed quietly rather than erroring on the missing record.
Where it gets genuinely hard
Worth being honest about, because the easy cases above cover most but not all of it.
Multiple side effects in one operation. If a handler creates a record, sends an email, and calls an external service, a crash between steps leaves a partial state — and the retry must know which steps completed. Recording progress per step is the usual answer.
Non-idempotent external services. When the other side offers no key and no way to check, you can record your intent before calling and reconcile after, which narrows the window rather than closing it.
Concurrent duplicates. Two copies of the same message processed simultaneously both check, both find nothing, and both proceed. A unique constraint in the database is what actually prevents this; a check-then-act in code does not.
Long-running work. A process that takes minutes may be retried while the first attempt is still running, so the key needs to be claimed at the start rather than recorded at the end.
Results that differ legitimately. Returning a cached original result is correct for a duplicate and wrong if the caller expects current state, so be clear which you are offering.
Calling APIs that support it
Many payment and commerce APIs accept an idempotency key on the request, and using it is straightforward and frequently skipped.
Generate it once, per logical operation, and reuse it across every retry of that operation.
Store it alongside your record of the operation, so a retry after a process restart still uses the same key.
Read the documented window, since it varies and a retry outside it is a new request.
Understand the response. A repeated request typically returns the original result, which may look like success when nothing new happened — that is correct behaviour and worth logging distinctly.
Do not reuse a key across different operations, which some APIs reject and others silently treat as a duplicate.
Testing that it actually holds
Idempotency is a property that looks present until the moment it is needed, which makes it worth testing explicitly.
Run the operation twice in the test, always, and assert the state after two runs equals the state after one. This single assertion catches most implementation errors.
Run it twice concurrently. Sequential duplicates are the easy case; simultaneous ones are where a check-then-act quietly fails, and only a concurrent test surfaces that.
Test the crash between steps. Interrupt a multi-step operation after the first side effect and retry it, which is the scenario production produces routinely and test suites almost never do.
Test the late duplicate, outside the key window, so you know what happens rather than assuming.
Assert on side effects, not return values. An operation can return the same response twice while sending two emails, and only checking the side effect catches it.
Keep one of these tests per operation with an external effect. They are cheap to write and they are the only thing standing between a retry policy and a duplicate charge.
Building it with CodeWords
CodeWords connects to more than 3,000 integrations, and handling repeats is part of describing the automation rather than something to engineer separately.
- Open CodeWords and start a new automation.
- Describe what should happen in plain language to Cody, the automation builder, including what identifies a piece of work uniquely.
- Say what should happen on a repeat: ignore it, update the existing record, or report it.
- Describe the exceptions: a partial failure, a repeat arriving while the first is still running, a duplicate arriving days later.
You describe the outcome; Cody builds it, connects it, and deploys it. The free plan covers light use, with Pro at $39 per month and Business at $100 per month as usage grows; details are on the pricing page.
What to get right first
The key. Derive it from something in the source that is identical on every attempt — the order, the event, the message — and never from the attempt itself. Everything else on this page is mechanism; if the key changes between retries, none of the mechanism does anything.
Two habits make the difference. Let the database enforce uniqueness with a constraint rather than checking in code first, because a check-then-act does not survive two copies running at once. And log when a duplicate was detected and suppressed, since that count is the only evidence the protection is working and it is usually higher than anybody expects.
Frequently asked questions
Why does my idempotency check not work?
Almost always because the key is generated per attempt rather than derived from the request. A fresh key on each retry never matches a stored one, which makes the mechanism a no-op.
Is a check-then-act enough?
Not under concurrency. Two copies processing simultaneously both check, both find nothing, and both proceed. A unique constraint in the database is what actually prevents it.
How long should keys be kept?
At least as long as anything can retry. If a queue redelivers for three days, a one-hour window leaves you unprotected exactly when a backlog means retries are slow.
Can I make sending an email idempotent?
Only by recording that it was sent, keyed on the triggering event, and checking before sending. There is no way to recall one, so the protection has to be before the send rather than after.
What about an operation with several side effects?
Record progress per step, so a retry knows which steps completed. A crash between steps is the common case, and a handler that treats its work as atomic when it is not will redo the completed parts.
Is exactly-once processing possible?
Effectively, yes — as at-least-once delivery plus idempotent processing. As a delivery guarantee across a network, no. The combination produces the outcome people mean when they ask for it.
Should a duplicate return an error?
Usually not. Returning the original result is the more useful behaviour, since the caller is retrying precisely because it does not know the first attempt succeeded. Log it distinctly so you can see how often it happens.
How do I test for idempotency?
Run the operation twice and assert the resulting state matches one run — then run it twice concurrently, which is where a check-then-act fails. Assert on side effects rather than return values, since a handler can return the same response twice while sending two emails.
What scenario do test suites almost always miss?
A crash between steps of a multi-step operation, followed by a retry. Production produces it routinely, tests rarely simulate it, and it is where partial state and duplicated side effects come from.