• APIs
  • Idempotency

Idempotency and duplicate requests

An idempotency key identifies retries of the same operation. A database uniqueness constraint can prevent duplicate records, while other side effects need their own coordination. This matters when an order succeeds but its reply is lost: the client must be able to retry without creating a second order.

a little experiment

Requests and recorded orders

A local simulation of repeated requests for one intended operation.

requests received0
orders created0

current key: checkout-1 · no order yet

Submit the same action twice. Compare the request count with the order count.

A repeated request is an ordinary situation

Imagine the server receives an order and saves it successfully. The connection drops before the reply reaches your browser, so the browser cannot tell whether the order succeeded. A retry might be sensible, but creating another order can duplicate the work.

Repeating an idempotent operation gives the same intended effect. APIs can use an idempotency key to identify retries and return a previously recorded result. Stripe documents one practical implementation.

The sketch above models two requests for one action. Disable protection. Submit twice. Then reset the example. Enable protection. Submit twice again.

The request count stays at two while the number of orders changes.

Give the action an identity before sending it

two attempts, one intended order
POST /orders HTTP/1.1
Idempotency-Key: checkout-7f3a
Content-Type: application/json

{"item": "notebook", "quantity": 1}

Create a key when the user starts an operation. Preserve it across retries. A fresh key for every request makes those requests look like different operations. A new order needs a new key.

A server should associate the key with the caller and operation. It should check that repeated uses have the same request data. If an order changes, the server should reject reuse of its key. Otherwise, it could return an unrelated result. Stripe's idempotency behavior shows how it checks repeated parameters.

Make the database enforce uniqueness

Consider two requests arriving together. Both ask whether a key exists. Each sees that it does not exist and tries to create an order. An application-level check followed by a separate insert leaves room for that race.

A database uniqueness constraint can enforce that a key appears only once within a chosen scope. PostgreSQL documents uniqueness across groups of columns.

Two requests with the same checkout key lead to one recorded order. A uniqueness constraint on customer and key prevents a duplicate record, and the retry reuses the result.
In this database example, retries with the same customer and key return the existing order.
a PostgreSQL schema for this example
CREATE TABLE orders (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_id BIGINT NOT NULL,
  idempotency_key TEXT NOT NULL,
  request_body JSONB NOT NULL,
  UNIQUE (customer_id, idempotency_key)
);

For an operation that only creates this row, the winning insert records the order. A conflicting attempt can read the existing row, compare the request, and reuse the result. More complicated workflows must also coordinate their side effects and recorded results.

A small experiment you can run

This Python example uses an in-memory SQLite database, so it needs no database server. It models one customer ordering one item. The uniqueness constraint rejects the repeated key, and the function retrieves the saved result. Python's sqlite3 documentation covers parameter binding and transaction handling.

save as orders.py, then run python3 orders.py
import sqlite3

db = sqlite3.connect(':memory:')
db.execute('''
  CREATE TABLE orders (
    id INTEGER PRIMARY KEY,
    customer TEXT NOT NULL,
    key TEXT NOT NULL,
    item TEXT NOT NULL,
    UNIQUE(customer, key)
  )
''')

def submit(customer, key, item):
    try:
        with db:
            db.execute(
                'INSERT INTO orders(customer, key, item) VALUES (?, ?, ?)',
                (customer, key, item),
            )
    except sqlite3.IntegrityError:
        pass

    row = db.execute(
        'SELECT id, item FROM orders WHERE customer = ? AND key = ?',
        (customer, key),
    ).fetchone()
    if row is None or row[1] != item:
        raise ValueError('Key reused with different request data')
    return row[0]

print(submit('alice', 'checkout-1', 'notebook'))
print(submit('alice', 'checkout-1', 'notebook'))
print(db.execute('SELECT COUNT(*) FROM orders').fetchone()[0])
# Expected output: 1, 1, 1 (on separate lines)

Change the item in the second call while keeping its key. You should get an error. Change the key instead. You should get a second order. These variations show which requests count as the same operation.

Keep the guarantee within its boundaries

The example only records database rows. It does not send emails, reserve inventory, or contact another service. A unique order row alone cannot prevent those separate effects from happening twice. A real design must coordinate their identities and recovery paths.

Decide how long keys remain valid. Define what a second request sees while the first still runs. Keeping keys forever and expiring them quickly have different consequences. Document that contract so clients know when a retry remains safe.

Draw three moments: before the insert, after the insert, and after the reply. Imagine a failure at each point. Record what a retry should return. Include any work that might already be complete.

read further

leave a note