{"post":{"slug":"idempotency-keys-explained-2026","title":"Idempotency Keys Explained: 2026 Guide","excerpt":"An idempotency key is what lets a client safely retry a request that may have already succeeded. This guide covers key generation, storage, request hashing, and concurrency handling in practice.","category":"code","date":"August 6, 2026","author":"ByteLedger Team","content":"<span class=\"dropcap\">A</span>n idempotency key is a token the client generates and attaches to a request so the server can recognize a retry and return the original result instead of executing the operation again. The concept is simple to state; the implementation has several details that determine whether it actually works under concurrency and partial failure. How you store the key, what you hash alongside it, and how you lock against simultaneous duplicate requests are what separate a correct implementation from one that only looks correct in testing.\n\n## How it works\n\nThe client generates a unique key before the first attempt and sends it on every retry of that same logical request.\n\n```\n1. Client generates key K (UUID or ULID) before the first attempt.\n2. Client -> POST /charges   Idempotency-Key: K\n3. Server checks storage for K.\n   - Not found: process the charge, store (K, request_hash, response), return response.\n   - Found, same request_hash: return the stored response without reprocessing.\n   - Found, different request_hash: reject with 422 -- same key, different payload.\n4. Network failure or timeout -> client retries with the same key K.\n5. Server sees K already stored -> returns the original response.\n```\n\nThe request hash is what separates a correct implementation from a naive one. Without it, a client that accidentally reuses a key for a genuinely different request gets the wrong response replayed back silently.\n\n## Building the storage layer\n\n```sql\nCREATE TABLE idempotency_keys (\n  key             TEXT PRIMARY KEY,\n  request_hash    TEXT NOT NULL,\n  response_status INT NOT NULL,\n  response_body   JSONB NOT NULL,\n  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),\n  locked_at       TIMESTAMPTZ\n);\n```\n\n```python\nimport hashlib, json\n\ndef request_hash(body: dict) -> str:\n    canonical = json.dumps(body, sort_keys=True)\n    return hashlib.sha256(canonical.encode()).hexdigest()\n\ndef handle_request(key, body):\n    h = request_hash(body)\n    existing = db.query(\"SELECT * FROM idempotency_keys WHERE key = %s\", key)\n\n    if existing:\n        if existing.request_hash != h:\n            return error(422, \"Idempotency key reused with a different request\")\n        return existing.response_body  # replay, do not reprocess\n\n    # Insert first, relying on the primary key to block concurrent duplicates\n    try:\n        db.execute(\n            \"INSERT INTO idempotency_keys (key, request_hash, response_status, response_body) \"\n            \"VALUES (%s, %s, %s, %s)\",\n            key, h, 202, {\"status\": \"processing\"},\n        )\n    except UniqueViolation:\n        return error(409, \"Request with this key is already being processed\")\n\n    result = do_the_actual_work(body)\n    db.execute(\n        \"UPDATE idempotency_keys SET response_status = %s, response_body = %s WHERE key = %s\",\n        result.status, result.body, key,\n    )\n    return result.body\n```\n\nThe insert-first pattern is what handles concurrency: two simultaneous requests with the same key race on the same primary key insert, and the database's uniqueness guarantee ensures only one wins.\n\n## Storage backend comparison\n\n| Backend | Concurrency guarantee | TTL/expiry | Best for |\n| --- | --- | --- | --- |\n| Postgres unique constraint | Strong (ACID) | Manual cleanup job or pg_cron | Systems already on Postgres; strongest correctness |\n| Redis with SETNX | Strong, single-node; needs care in cluster mode | Native EXPIRE | High-throughput APIs already using Redis |\n| DynamoDB conditional write | Strong | Native TTL attribute | AWS-native serverless stacks |\n| In-memory cache only | None across replicas | N/A | Never use for idempotency across multiple servers |\n\nWhichever backend you pick, the requirement is the same: the check-and-store step must be atomic, or two concurrent requests can both slip through before either has written its key.\n\n## Common mistakes\n\n**Checking for the key, then inserting it, as two separate non-atomic steps.** This race condition lets two concurrent requests both pass the \"not found\" check before either writes, defeating the entire mechanism. Use a single atomic insert or conditional write.\n\n**Not hashing the request body.** Storing only the key means a client that mistakenly reuses a key for a different request gets the old response silently replayed instead of a clear error.\n\n**Storing the key only in application memory or a single-node cache.** Any deployment with more than one server instance needs a shared store, such as Postgres, Redis, or DynamoDB, or a retry can land on a different instance that has never seen the key.\n\n**No expiry policy.** An idempotency key table with no TTL grows forever and slows down lookups. Set a TTL that covers your realistic retry window, hours to days rather than months, and clean up on a schedule.\n\n## FAQ\n\n**Who generates the idempotency key, the client or the server?**\nThe client, always, and before the first attempt. If the server generates it, a request that times out before the client receives a response has no key to retry with.\n\n**What should the server do if the same key arrives with a different request body?**\nReject it, typically with 422 or 409, rather than either silently processing the new payload or silently returning the old response. A changed payload under a reused key indicates a client bug.\n\n**How long should an idempotency key be stored?**\nLong enough to cover the realistic retry window for your system, often 24 hours for a payment API, longer for asynchronous workflows. Expire and clean up on a schedule rather than storing indefinitely.\n\n**Is a database unique constraint alone enough for idempotency?**\nIt prevents a duplicate row, but you also need to store and return the original response, and ideally hash the request body to catch a reused key with a different payload. The constraint alone only solves the concurrency half of the problem.\n\n## Where to go next\n\nFor the broader concept, which HTTP methods are idempotent by nature and when you need a key at all, see [idempotency explained in 2026](/blog/idempotency-explained-2026). If you are rolling out idempotency keys as part of a version change, [API deprecation strategy for 2026](/blog/api-deprecation-strategy-2026) covers keeping old and new clients working during the transition, and [how to write a database migration in 2026](/blog/how-to-write-a-database-migration-2026) covers adding the storage table safely.","hue":302,"metaTitle":"Idempotency Keys Explained 2026: How It Works","metaDescription":"How idempotency keys work in 2026: client-side generation, storage schema, request hashing to catch payload mismatches, concurrency locks, and expiry.","keywords":["idempotency keys explained 2026","idempotency key implementation","idempotency key storage schema","request hash idempotency","idempotency key concurrency lock","stripe idempotency key pattern","idempotency key expiry ttl","distributed system retry safety","idempotency key redis postgres"],"tldr":["**An idempotency key is client-generated** — a token attached to a request so the server can recognize a retry and return the original result.","**Hash the request body too** — storing a hash of the payload alongside the key lets you detect and reject a reused key sent with different data.","**Lock on the first use** — a database unique constraint or a distributed lock stops two simultaneous requests with the same key from both executing.","**Store the full response body** — a retry needs the exact original response to behave correctly, not a generic acknowledgment.","**Skip:** letting idempotency keys live forever — expire them on a TTL that covers your realistic retry window, and clean up the table on a schedule."],"readTime":"4 min read","wordCount":959},"related":[{"slug":"idempotency-explained-2026","title":"Idempotency explained in 2026: keys, retries, and safe API design","excerpt":"Idempotency is the property that makes retries safe. This 2026 guide explains idempotency keys, when operations are naturally idempotent, and how to build APIs that survive network failures without double-charging users.","category":"code","date":"March 2, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":315},{"slug":"distributed-locks-explained-2026","title":"Distributed Locks Explained in 2026: How They Work","excerpt":"Distributed locks coordinate exclusive access across machines, not just threads. This guide explains leases, fencing tokens, and how to pick between Redis, etcd, and a plain database lock.","category":"code","date":"July 31, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":314},{"slug":"what-is-a-database-deadlock-2026","title":"What is a database deadlock? The 2026 guide to detection","excerpt":"A database deadlock is not the same as a slow query or a stuck lock wait. Here is how the engine actually detects one, picks a victim, and resolves it.","category":"code","date":"July 17, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":260}],"sameCategory":[{"slug":"bazel-build-explained-2026","title":"Bazel Explained: What Hermetic Builds Actually Cost","excerpt":"Bazel delivers reproducible, precisely cached builds and demands that you describe everything. Here is what you get, what it costs, and who should say no.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":145},{"slug":"graphql-federation-2026","title":"GraphQL Federation: One Graph, Many Teams","excerpt":"Federation composes independently-owned subgraphs into a single API. Here is what it solves, the operational cost it adds, and when a simpler gateway is enough.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":300},{"slug":"hermetic-builds-explained-2026","title":"Hermetic Builds Explained: Same Inputs, Same Output","excerpt":"A hermetic build depends only on declared inputs. Here is what that property gives you, the common leaks that break it, and how to move toward it incrementally.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":130},{"slug":"monorepo-tooling-compared-2026","title":"Monorepo Tooling Compared: What You Actually Need","excerpt":"Monorepo tools solve task orchestration and caching, not repository layout. Here is what each tier provides and how to tell when you have outgrown the simple option.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":150}],"newer":{"slug":"i-bonds-explained-2026","title":"I Bonds Explained: How Series I Bonds Work","excerpt":"Series I savings bonds combine a fixed rate with an inflation adjustment that resets twice a year. Here is how the rate is set, the limits, and the lockup rules for 2026.","category":"finance","date":"August 6, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":322},"older":{"slug":"immutability-in-practice-2026","title":"Immutability in Practice: Why Debugging Gets Easier","excerpt":"Immutable data cannot change out from under you, which removes one of the hardest categories of bug to trace: state that mutated somewhere else in the program. Here is how that guarantee simplifies debugging in 2026.","category":"code","date":"August 6, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":300}}