---
# === IDENTITY ===
id: software/patterns/idempotency-patterns/2026
canonical_question: "How do I implement idempotency in APIs and distributed systems?"
aliases:
  - "idempotent API design"
  - "idempotency key implementation"
  - "safe API retry patterns"
  - "preventing duplicate operations"
  - "Idempotency-Key header"
  - "at-least-once idempotent consumer"
  - "message deduplication patterns"
  - "distributed systems idempotency"
entity_type: software_reference
domain: software > patterns > idempotency patterns
region: global
jurisdiction: global
temporal_scope: 2020-2026

# === VERIFICATION ===
last_verified: 2026-02-24
confidence: 0.92
version: 1.0
first_published: 2026-02-24

# === TEMPORAL VALIDITY ===
temporal_validity:
  status: stable
  last_breaking_change: "IETF draft-ietf-httpapi-idempotency-key-header-07 (2024) standardizes the Idempotency-Key header format"
  next_review: 2026-08-23
  change_sensitivity: low

# === CONSTRAINTS ===
constraints:
  - "Idempotency check MUST happen BEFORE mutation -- checking after write is useless"
  - "Idempotency key + execute MUST be atomic (single transaction or distributed lock) to prevent race conditions"
  - "Idempotency keys MUST have a TTL (24h-30d typical) -- unbounded storage causes OOM or table bloat"
  - "NEVER reuse an idempotency key with different request parameters -- servers must reject with 422"
  - "Client-generated keys MUST use cryptographically random UUIDs (v4) -- sequential IDs enable guessing attacks"

# === SKIP CONDITIONS ===
skip_this_unit_if:
  - condition: "Need exactly-once message delivery guarantees from the broker itself"
    use_instead: "Message broker documentation (Kafka exactly-once, SQS FIFO deduplication)"
  - condition: "Need database-level conflict resolution without application logic"
    use_instead: "software/patterns/sql-upsert/2026"
  - condition: "Need to prevent concurrent writes to the same resource"
    use_instead: "Optimistic concurrency control / ETags"

# === AGENT HINTS ===
inputs_needed:
  - key: layer
    question: "Which layer needs idempotency?"
    type: choice
    options: ["API", "database", "message queue"]
  - key: storage
    question: "What storage is available for idempotency state?"
    type: choice
    options: ["PostgreSQL", "Redis", "DynamoDB", "in-memory"]
  - key: ttl_requirements
    question: "How long should idempotency keys be retained?"
    type: choice
    options: ["1 hour", "24 hours", "7 days", "30 days"]

# === DISTRIBUTION ===
canonical_source: "https://knowledgelib.io/software/patterns/idempotency-patterns/2026"
suggested_citation: "Source: knowledgelib.io -- AI Knowledge Library (verified 2026-02-24)"

# === RELATED UNITS ===
related_kos:
  depends_on: []
  related_to:
    - id: software/system-design/webhooks-system/2026
      label: "Webhook System Design"
    - id: software/system-design/message-queue-event-driven/2026
      label: "Message Queue & Event-Driven Architecture"
    - id: software/system-design/payment-processing/2026
      label: "Payment Processing System Design"
    - id: software/system-design/api-gateway/2026
      label: "API Gateway Design"
  solves: []
  alternative_to: []
  often_confused_with:
    - id: software/patterns/sql-upsert/2026
      label: "SQL UPSERT (INSERT ON CONFLICT) -- handles DB-level dedup, not API-level"

# === SOURCES (7 authoritative sources) ===
# Types: official_docs, technical_blog, rfc_spec, community_resource
# Reliability: high, moderate_high, moderate, authoritative
sources:
  - id: src1
    title: "Designing robust and predictable APIs with idempotency"
    author: Brandur Leach (Stripe)
    url: https://stripe.com/blog/idempotency
    type: technical_blog
    published: 2017-06-13
    reliability: authoritative
  - id: src2
    title: "Implementing Stripe-like Idempotency Keys in Postgres"
    author: Brandur Leach
    url: https://brandur.org/idempotency-keys
    type: technical_blog
    published: 2017-10-15
    reliability: high
  - id: src3
    title: "Idempotent Requests -- Stripe API Reference"
    author: Stripe
    url: https://docs.stripe.com/api/idempotent_requests
    type: official_docs
    published: 2024-01-01
    reliability: authoritative
  - id: src4
    title: "The Idempotency-Key HTTP Header Field (draft-ietf-httpapi-idempotency-key-header-07)"
    author: IETF HTTPAPI Working Group
    url: https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
    type: rfc_spec
    published: 2024-11-04
    reliability: authoritative
  - id: src5
    title: "Idempotent Consumer Pattern"
    author: Chris Richardson (microservices.io)
    url: https://microservices.io/patterns/communication-style/idempotent-consumer.html
    type: technical_blog
    published: 2020-10-16
    reliability: high
  - id: src6
    title: "Idempotency -- AWS Lambda Documentation"
    author: Amazon Web Services
    url: https://docs.aws.amazon.com/lambda/latest/dg/durable-execution-idempotency.html
    type: official_docs
    published: 2025-06-01
    reliability: high
  - id: src7
    title: "AWS Lambda Powertools -- Idempotency Utility"
    author: Amazon Web Services
    url: https://docs.aws.amazon.com/powertools/python/latest/utilities/idempotency/
    type: official_docs
    published: 2025-01-01
    reliability: high
---

# Idempotency Patterns for APIs and Distributed Systems

## TL;DR

- **Bottom line**: Combine an idempotency key header with an atomic check-and-execute pattern (DB unique constraint or Redis SETNX) to make any mutating API endpoint safely retryable.
- **Key tool/command**: `Idempotency-Key` HTTP header + database/Redis store with TTL
- **Watch out for**: Race conditions between the idempotency check and the mutation -- these must be atomic (single transaction or distributed lock).
- **Works with**: Any HTTP API framework, any database (PostgreSQL, MySQL, DynamoDB), Redis, message brokers (Kafka, SQS, RabbitMQ).

## Constraints
<!-- Agents: read this section before recommending any action from this unit.
     These are hard constraints that must not be violated. -->

- Idempotency check MUST happen BEFORE mutation -- checking after write is useless
- Idempotency key + execute MUST be atomic (single transaction or distributed lock) to prevent race conditions
- Idempotency keys MUST have a TTL (24h-30d typical) -- unbounded storage causes OOM or table bloat
- NEVER reuse an idempotency key with different request parameters -- servers must reject with 422
- Client-generated keys MUST use cryptographically random UUIDs (v4) -- sequential IDs enable guessing attacks

## Quick Reference

| Pattern | Layer | Complexity | Storage Needed | Best For |
|---|---|---|---|---|
| Idempotency-Key header | API | Medium | DB or Redis | POST endpoints (payments, orders) |
| Natural idempotency (PUT/DELETE) | API | Low | None (inherent) | Resource updates, deletions |
| Database unique constraint | Database | Low | DB index | Preventing duplicate inserts |
| Optimistic locking (version/ETag) | Database | Medium | Version column | Concurrent update conflicts |
| Message deduplication (inbox pattern) | Message queue | Medium | DB table | At-least-once consumers |
| At-least-once + idempotent consumer | Message queue | Medium-High | DB or Redis | Event-driven microservices |
| Conditional writes (DynamoDB) | Database | Low | Built-in | Serverless, AWS-native stacks |
| Client-generated resource ID | API | Low | None (resource table) | Simple CRUD APIs |

## Decision Tree

```
START
|-- Is the operation naturally idempotent (GET, PUT with full resource, DELETE)?
|   |-- YES --> No idempotency mechanism needed; ensure PUT replaces full resource
|   +-- NO (POST, PATCH, or side-effect-producing operation) |
|       |-- Is this an API endpoint (HTTP)?
|       |   |-- YES --> Use Idempotency-Key header pattern
|       |   |   |-- Is latency critical (<10ms overhead)?
|       |   |   |   |-- YES --> Use Redis SETNX for idempotency store
|       |   |   |   +-- NO --> Use database table with unique constraint
|       |   |   +-- Do you need to store the full response?
|       |   |       |-- YES --> Store response_code + response_body (Stripe pattern)
|       |   |       +-- NO --> Store only key + status (lighter)
|       |   +-- NO (message queue / event consumer)?
|       |       |-- Does the broker support native dedup (SQS FIFO, Kafka EOS)?
|       |       |   |-- YES --> Enable broker-level dedup + application-level as defense-in-depth
|       |       |   +-- NO --> Implement idempotent consumer (inbox pattern with DB)
|       |       +-- Are messages processed in transactions?
|       |           |-- YES --> Store message ID in same transaction as business logic
|       |           +-- NO --> Use separate dedup table with TTL cleanup
+-- DEFAULT --> Add Idempotency-Key header middleware + PostgreSQL store
```

## Step-by-Step Guide

### 1. Create the idempotency key store

Create a database table (or Redis structure) to track idempotency keys, their status, and cached responses. [src2]

```sql
-- PostgreSQL idempotency key table
CREATE TABLE idempotency_keys (
    id              BIGSERIAL PRIMARY KEY,
    idempotency_key TEXT NOT NULL,
    user_id         TEXT NOT NULL,           -- scope keys per client/user
    request_method  TEXT NOT NULL,
    request_path    TEXT NOT NULL,
    request_hash    TEXT NOT NULL,           -- SHA-256 of request body
    status          TEXT NOT NULL DEFAULT 'started',  -- started | processing | finished
    response_code   INT,
    response_body   JSONB,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    locked_at       TIMESTAMPTZ,
    CONSTRAINT uq_idempotency UNIQUE (user_id, idempotency_key)
);

-- TTL cleanup: index for efficient deletion
CREATE INDEX idx_idempotency_created ON idempotency_keys (created_at);
```

**Verify**: `SELECT COUNT(*) FROM idempotency_keys;` --> expected: `0` (empty table ready)

### 2. Implement idempotency middleware

Add middleware that intercepts incoming requests, checks for the `Idempotency-Key` header, and either returns a cached response or proceeds with processing. [src1] [src4]

```javascript
// Express.js idempotency middleware (Node.js)
async function idempotencyMiddleware(req, res, next) {
  const key = req.headers['idempotency-key'];
  if (!key || req.method === 'GET') return next();

  const hash = crypto.createHash('sha256')
    .update(JSON.stringify(req.body)).digest('hex');
  const userId = req.user?.id || req.ip;

  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    // Atomic check: INSERT or detect existing
    const { rows } = await client.query(`
      INSERT INTO idempotency_keys (idempotency_key, user_id, request_method, request_path, request_hash, status)
      VALUES ($1, $2, $3, $4, $5, 'processing')
      ON CONFLICT (user_id, idempotency_key) DO UPDATE
        SET locked_at = now()
      RETURNING status, response_code, response_body, request_hash
    `, [key, userId, req.method, req.path, hash]);

    const existing = rows[0];
    if (existing.status === 'finished') {
      // Verify same request params
      if (existing.request_hash !== hash) {
        await client.query('ROLLBACK');
        return res.status(422).json({ error: 'Idempotency key reused with different parameters' });
      }
      await client.query('COMMIT');
      return res.status(existing.response_code).json(existing.response_body);
    }
    await client.query('COMMIT');

    // Capture response to store later
    const originalJson = res.json.bind(res);
    res.json = async (body) => {
      await pool.query(`
        UPDATE idempotency_keys SET status = 'finished', response_code = $1, response_body = $2
        WHERE user_id = $3 AND idempotency_key = $4
      `, [res.statusCode, JSON.stringify(body), userId, key]);
      return originalJson(body);
    };
    next();
  } catch (err) {
    await client.query('ROLLBACK');
    next(err);
  } finally {
    client.release();
  }
}
```

**Verify**: Send the same POST twice with the same `Idempotency-Key` header --> second response should be identical with no side effects.

### 3. Add TTL cleanup

Schedule periodic cleanup of expired idempotency keys to prevent table bloat. [src3]

```sql
-- Delete keys older than 24 hours (run via cron or pg_cron)
DELETE FROM idempotency_keys
WHERE created_at < now() - INTERVAL '24 hours';
```

```javascript
// Node.js cleanup job (run every hour)
const cron = require('node-cron');
cron.schedule('0 * * * *', async () => {
  const { rowCount } = await pool.query(
    `DELETE FROM idempotency_keys WHERE created_at < now() - INTERVAL '24 hours'`
  );
  console.log(`Cleaned up ${rowCount} expired idempotency keys`);
});
```

**Verify**: `SELECT COUNT(*) FROM idempotency_keys WHERE created_at < now() - INTERVAL '24 hours';` --> expected: `0`

### 4. Implement message-level deduplication (for queue consumers)

For event-driven systems, add an inbox table and check message IDs before processing. [src5]

```sql
-- Processed messages table (inbox pattern)
CREATE TABLE processed_messages (
    subscriber_id TEXT NOT NULL,
    message_id    TEXT NOT NULL,
    processed_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (subscriber_id, message_id)
);
```

```javascript
// Idempotent message consumer
async function handleMessage(subscriberId, message) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    // Attempt to record message -- fails on duplicate
    await client.query(
      `INSERT INTO processed_messages (subscriber_id, message_id) VALUES ($1, $2)`,
      [subscriberId, message.id]
    );
    // Process the message within the same transaction
    await processBusinessLogic(client, message);
    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    if (err.code === '23505') { // unique_violation
      console.log(`Duplicate message ${message.id} -- skipping`);
      return; // Message already processed
    }
    throw err;
  } finally {
    client.release();
  }
}
```

**Verify**: Publish the same message twice --> business logic executes exactly once, second attempt logs "Duplicate message -- skipping".

## Code Examples

### Node.js/Express: Redis-Backed Idempotency Middleware

> Full script: [node-js-express-redis-backed-idempotency-middlewar.js](scripts/node-js-express-redis-backed-idempotency-middlewar.js) (36 lines)

```javascript
// Input:  HTTP request with Idempotency-Key header
// Output: Cached response on retry, fresh response on first call
const crypto = require('crypto');
const Redis = require('ioredis');        // ioredis ^5.0.0
const redis = new Redis(process.env.REDIS_URL);
# ... (see full script)
```

### Python/FastAPI: Database-Backed Idempotency Decorator

> Full script: [python-fastapi-database-backed-idempotency-decorat.py](scripts/python-fastapi-database-backed-idempotency-decorat.py) (41 lines)

```python
# Input:  FastAPI request with Idempotency-Key header
# Output: Cached response on retry, fresh response on first call
import hashlib, json
from functools import wraps
from fastapi import Request, HTTPException
# ... (see full script)
```

### Go: Idempotency Middleware with PostgreSQL

> Full script: [go-idempotency-middleware-with-postgresql.go](scripts/go-idempotency-middleware-with-postgresql.go) (57 lines)

```go
// Input:  HTTP request with Idempotency-Key header
// Output: Cached response on replay, fresh response on first call
package middleware
import (
    "crypto/sha256"
# ... (see full script)
```

## Anti-Patterns

### Wrong: Checking idempotency AFTER mutation

```javascript
// BAD -- mutation happens before dedup check; damage already done
app.post('/api/orders', async (req, res) => {
  const order = await db.query('INSERT INTO orders ... RETURNING *');  // side effect!
  const existing = await db.query(
    'SELECT * FROM idempotency_keys WHERE key = $1', [req.headers['idempotency-key']]
  );
  if (existing.rows.length) return res.json(existing.rows[0].response);
  // Too late -- order already created
});
```

### Correct: Atomic check-then-execute

```javascript
// GOOD -- idempotency check is first, within the same transaction
app.post('/api/orders', async (req, res) => {
  const client = await pool.connect();
  await client.query('BEGIN');
  const { rows } = await client.query(
    `INSERT INTO idempotency_keys (idempotency_key, ...) VALUES ($1, ...)
     ON CONFLICT (user_id, idempotency_key) DO UPDATE SET locked_at = now()
     RETURNING status, response_code, response_body`,
    [req.headers['idempotency-key']]
  );
  if (rows[0].status === 'finished') {
    await client.query('COMMIT');
    return res.status(rows[0].response_code).json(rows[0].response_body);
  }
  const order = await client.query('INSERT INTO orders ... RETURNING *');
  await client.query(
    `UPDATE idempotency_keys SET status='finished', response_code=201, response_body=$1 WHERE ...`,
    [JSON.stringify(order.rows[0])]
  );
  await client.query('COMMIT');
  client.release();
  res.status(201).json(order.rows[0]);
});
```

### Wrong: No TTL on idempotency keys

```javascript
// BAD -- keys accumulate forever, table grows unbounded
await db.query(
  `INSERT INTO idempotency_keys (key, response) VALUES ($1, $2)`,
  [key, response]
);
// No cleanup job, no TTL, no partition pruning
```

### Correct: TTL with automated cleanup

```javascript
// GOOD -- keys expire and are cleaned up
// Option A: Application-level cleanup (cron)
cron.schedule('0 * * * *', () =>
  db.query(`DELETE FROM idempotency_keys WHERE created_at < now() - INTERVAL '24 hours'`)
);

// Option B: Redis with built-in TTL
await redis.set(`idem:${key}`, response, 'EX', 86400);

// Option C: DynamoDB with TTL attribute
await dynamodb.put({ Item: { pk: key, ttl: Math.floor(Date.now()/1000) + 86400 } });
```

### Wrong: Non-atomic check-and-execute with race condition

```javascript
// BAD -- race condition: two concurrent requests both pass the check
const existing = await db.query('SELECT * FROM idempotency_keys WHERE key=$1', [key]);
if (existing.rows.length > 0) return res.json(existing.rows[0].response);
// Window of vulnerability: another request can arrive HERE before insert
await db.query('INSERT INTO idempotency_keys (key) VALUES ($1)', [key]);
await processPayment(); // Both requests process the payment!
```

### Correct: Atomic upsert eliminates race window

```javascript
// GOOD -- INSERT ON CONFLICT is atomic; no race window
const { rows } = await db.query(`
  INSERT INTO idempotency_keys (idempotency_key, user_id, status)
  VALUES ($1, $2, 'processing')
  ON CONFLICT (user_id, idempotency_key) DO UPDATE SET locked_at = now()
  RETURNING status, response_code, response_body
`, [key, userId]);
// If status is 'finished', return cached response
// If status is 'processing', this request owns the lock
```

## Common Pitfalls

- **Client-generated UUIDs without server validation**: Clients can send malformed or predictable keys. Fix: validate key format server-side (`/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i`) and enforce max length (255 chars). [src3]
- **Storing idempotency keys without user/client scoping**: One user could replay another user's key. Fix: always use `(user_id, idempotency_key)` as the composite unique constraint. [src2]
- **Returning 200 for in-progress requests**: If a retry arrives while the original is still processing, returning success is wrong. Fix: return `409 Conflict` with `Retry-After` header. [src4]
- **Not hashing request body for parameter validation**: Allows the same key to be used with different payloads. Fix: store SHA-256 hash of request body and compare on retry; reject with 422 if mismatched. [src1]
- **Idempotency at the wrong layer**: Adding API idempotency but calling non-idempotent downstream services. Fix: propagate idempotency keys to downstream APIs (e.g., Stripe's `idempotency_key` parameter). [src2]
- **Message dedup relying solely on broker features**: SQS standard queues and Kafka without EOS can still deliver duplicates. Fix: always implement application-level dedup (inbox pattern) as defense in depth. [src5]

## Diagnostic Commands

```bash
# Check idempotency key table size and oldest key
psql -c "SELECT COUNT(*), MIN(created_at), MAX(created_at) FROM idempotency_keys;"

# Find stuck/orphaned processing keys (older than 5 minutes)
psql -c "SELECT * FROM idempotency_keys WHERE status = 'processing' AND locked_at < now() - INTERVAL '5 minutes';"

# Check Redis idempotency key count and memory
redis-cli KEYS "idem:*" | wc -l
redis-cli INFO memory | grep used_memory_human

# Verify TTL is set on a specific Redis key
redis-cli TTL "idem:user123:8e03978e-40d5-43e8-bc93-6894a57f9324"

# Check for duplicate processing in message consumer logs
grep -c "Duplicate message" /var/log/consumer.log
```

## When to Use / When Not to Use

| Use When | Don't Use When | Use Instead |
|---|---|---|
| POST endpoints that create resources or trigger side effects | GET requests (already idempotent by definition) | Standard HTTP caching |
| Payment processing, order creation, money transfers | PUT requests that replace the full resource | PUT is naturally idempotent |
| Webhook delivery and retry handling | Idempotent database operations (e.g., SET value = X) | Rely on natural idempotency |
| At-least-once message queue consumers | Real-time streaming with exactly-once broker semantics | Kafka transactions / SQS FIFO dedup |
| Multi-step distributed workflows (sagas) | Simple CRUD with no side effects | Database constraints (UNIQUE, UPSERT) |
| Unreliable network conditions (mobile clients, IoT) | High-throughput read-heavy APIs | Read caching / CDN |

## Important Caveats

- The IETF `Idempotency-Key` header spec (draft-07) is not yet an RFC -- it is a stable draft but not finalized; follow updates at [IETF HTTPAPI WG](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/)
- Redis-based idempotency loses state on restart if persistence is disabled -- use AOF or RDB persistence, or prefer database-backed storage for critical operations (payments)
- Idempotency does not equal exactly-once processing -- it provides at-most-once side effects for a given key, but the response may be lost if the connection drops after server-side execution
- Different cloud providers have different built-in idempotency: AWS Lambda Powertools uses DynamoDB, Azure Functions use Durable Entities, GCP Cloud Functions require manual implementation
- Composite keys (user_id + idempotency_key) are essential for multi-tenant systems -- without user scoping, one tenant can interfere with another's operations

## Related Units

- [Webhook System Design](/software/system-design/webhooks-system/2026)
- [Message Queue & Event-Driven Architecture](/software/system-design/message-queue-event-driven/2026)
- [Payment Processing System Design](/software/system-design/payment-processing/2026)
- [API Gateway Design](/software/system-design/api-gateway/2026)
- [SQL UPSERT Patterns](/software/patterns/sql-upsert/2026)
