Idempotency in APIs: Preventing Duplicate Operations
Learn what idempotency means in API design and why it matters for payments, retries, and distributed systems. With practical implementation patterns.
Your user clicks "Pay Now." The request goes out. Network hiccup — the response never arrives. Did the payment go through?
They click again. Now did they pay twice?
This is the idempotency problem.
What Is Idempotency?
An operation is idempotent if doing it multiple times has the same result as doing it once.
Idempotent:
Set price = $100 → Run 3 times → price = $100 ✓
DELETE /orders/123 → Run 3 times → order deleted once ✓
GET /users/123 → Run 100 times → same user data ✓
Not idempotent:
Increment counter by 1 → Run 3 times → counter +3, not +1 ✗
POST /orders (new order) → Run 3 times → 3 orders created ✗
Charge card $100 → Run 3 times → $300 charged ✗HTTP methods have idempotency expectations built in:
GET,HEAD,OPTIONS— safe (read-only, definitely idempotent)PUT,DELETE— idempotent by designPOST— not idempotent by default
Why It Matters: Retries Are Inevitable
Networks fail. Servers crash. Clients timeout. In any distributed system, you need retries. But retries on non-idempotent operations cause duplicates.
The failure scenarios:
- Request sent, server never received it → retry is safe
- Server processed it, response lost in transit → retry causes duplicate
- Server is slow, client times out and retries → server may process twice
You can't tell which scenario happened from the client's side. Idempotency lets you safely retry without checking first.
Idempotency Keys
The standard pattern for making non-idempotent operations safe: the client sends a unique key with the request. The server uses it to detect duplicates.
Client → POST /payments
Idempotency-Key: uuid-abc-123
Body: { amount: 100, card: ... }
Server:
1. Check if uuid-abc-123 was seen before
2. If yes → return stored response (don't process again)
3. If no → process payment, store response against uuid-abc-123This is what Stripe does. Every POST to Stripe supports Idempotency-Key:
import stripe
stripe.api_key = "sk_..."
# First call — processes payment
result = stripe.PaymentIntent.create(
amount=1000,
currency="usd",
idempotency_key="order_123_payment_attempt_1"
)
# Retry (network failed, client retries) — returns same result, no duplicate charge
result = stripe.PaymentIntent.create(
amount=1000,
currency="usd",
idempotency_key="order_123_payment_attempt_1" # Same key
)Implementing Idempotency
Here's a practical pattern using Redis to store processed requests:
import redis
import json
import uuid
from fastapi import FastAPI, Header, HTTPException, Request
from datetime import timedelta
app = FastAPI()
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
IDEMPOTENCY_TTL = timedelta(days=1) # Store keys for 24 hours
def idempotent(ttl: timedelta = IDEMPOTENCY_TTL):
def decorator(func):
async def wrapper(*args, idempotency_key: str = Header(None), **kwargs):
if not idempotency_key:
raise HTTPException(status_code=400, detail="Idempotency-Key header required")
cache_key = f"idem:{idempotency_key}"
# Check if we've seen this key before
cached = r.get(cache_key)
if cached:
# Return the stored response — don't process again
return json.loads(cached)
# Process the request
result = await func(*args, **kwargs)
# Store the result
r.setex(cache_key, int(ttl.total_seconds()), json.dumps(result))
return result
return wrapper
return decorator
@app.post("/payments")
@idempotent()
async def create_payment(request: Request, idempotency_key: str = Header(None)):
body = await request.json()
# Process payment (only runs once per idempotency key)
payment_id = process_payment(body["amount"], body["card_token"])
return {"payment_id": payment_id, "status": "success"}# Database-backed approach (more durable than Redis alone)
from sqlalchemy import Column, String, JSON, DateTime
from datetime import datetime, timedelta
class IdempotencyRecord(Base):
__tablename__ = "idempotency_records"
key = Column(String, primary_key=True)
response = Column(JSON)
created_at = Column(DateTime, default=datetime.utcnow)
expires_at = Column(DateTime)
def get_or_create_idempotent_response(key: str, handler):
record = db.query(IdempotencyRecord).filter_by(key=key).first()
if record and record.expires_at > datetime.utcnow():
return record.response # Return cached response
# Not seen before — process and store
result = handler()
record = IdempotencyRecord(
key=key,
response=result,
expires_at=datetime.utcnow() + timedelta(days=1)
)
db.add(record)
db.commit()
return resultThe Concurrent Request Problem
What if the same key arrives twice at the same time? You need a lock:
import redis.lock
def process_idempotent(key: str, handler):
cache_key = f"idem:{key}"
lock_key = f"idem_lock:{key}"
# Try to get cached response first (fast path)
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# Acquire lock to prevent concurrent processing
with r.lock(lock_key, timeout=30):
# Check again inside lock (another request may have processed while we waited)
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# Process once
result = handler()
r.setex(cache_key, 86400, json.dumps(result))
return resultWithout the lock, two concurrent requests with the same key could both check the cache (both miss), both process, and both write — causing a duplicate.
What to Include in Idempotency Keys
The client generates keys. They must be:
- Unique per operation attempt: Two different payment attempts = two different keys
- Stable across retries: Retry of the same attempt = same key
- Unpredictable: Use UUID v4 or a hash of the operation parameters
import uuid
# Simple: UUID per user action
key = str(uuid.uuid4())
# Or deterministic: hash of operation + user ID (dangerous if parameters change)
import hashlib
key = hashlib.sha256(f"{user_id}:{order_id}:{amount}:{timestamp_minute}".encode()).hexdigest()Designing Idempotent APIs
Beyond the idempotency key pattern, design your endpoints to be naturally idempotent where possible:
Use PUT instead of POST for updates: PUT /users/123/email with {email: "new@example.com"} is idempotent. Running it 3 times sets the same email. POST /users/123/update-email is ambiguous.
Avoid operations like "add 10 points": Instead, set the total — "set points to 150." The outcome is the same regardless of retries.
Return the same response on retry: Don't return 201 Created on the first call and 200 OK on retry. Some clients check status codes. Keep it consistent, or always return 200 with the resource state.
Key Takeaways
- Idempotency: running an operation multiple times = same result as running once
- GET, PUT, DELETE are idempotent by spec; POST is not
- Retries are unavoidable in distributed systems — idempotency makes them safe
- Idempotency keys: client sends a unique ID; server detects and returns cached responses for duplicates
- Store processed keys in Redis or database with a TTL (24 hours is common)
- Use locks to prevent concurrent requests with the same key from both processing
- Design endpoints with
PUTand absolute values where possible to be naturally idempotent
Idempotency is what separates reliable APIs from ones that silently charge users twice.
Related reading: REST API Design Best Practices · Rate Limiting Your API · Message Queues Explained
Enjoyed this article?
Get weekly insights on backend architecture, system design, and Go programming.
Related Posts
Continue reading with these related posts
Saga Pattern: Distributed Transactions Without 2PC
Learn how the saga pattern handles distributed transactions in microservices. Covers choreography vs orchestration, compensating transactions, and real examples.
Service Discovery: How Microservices Find Each Other
Learn how service discovery works in microservices. Covers client-side vs server-side discovery, Consul, etcd, and Kubernetes DNS with practical examples.
Database Sharding Explained: Scale to Millions of Users
Learn how database sharding works, when to use it, and common strategies. Covers horizontal partitioning, shard keys, and challenges with real examples.