A

Idempotency keys in Go and Postgres: a retry that never charges twice

3 min read ·
on this page

A client sends POST /orders. The server creates the order and starts writing the response. The connection drops. The client sees a timeout, so it does the only sensible thing: it sends the request again.

Now there are two orders. Nothing failed. Every component behaved correctly, and the customer was still charged twice.

GET, PUT and DELETE are safe to repeat by definition. POST is not. The fix is to let the client name the attempt, so the server can recognise a repeat.

The contract

The client generates a unique value per intent (a UUID is fine) and sends it as a header:

POST /ordersIdempotency-Key: 8f14e45f-ceea-4672-a1c2-2ae5d1a0b1f7

The server promises: same key, same request, same response. The first call does the work. Every later call returns the stored result without doing it again.

Two rules complete the contract. Same key with a different body is a client bug, so answer 422. And a key belongs to one user, so the primary key is (user_id, key), never key alone.

One table

CREATE TABLE idempotency_keys (    user_id      bigint      NOT NULL,    key          text        NOT NULL,    request_hash bytea       NOT NULL,    status       int,    response     bytea,    created_at   timestamptz NOT NULL DEFAULT now(),    PRIMARY KEY (user_id, key));

request_hash is a SHA-256 of the method, path and body. status and response stay NULL until the work is finished.

The trick: the key lives in the same transaction

The tempting design is a check-then-act: look the key up, and if it is missing, do the work and save it. That has a race. Two retries arrive together, both see nothing, both do the work.

Let the database be the lock instead:

func (s Store) Do(	ctx context.Context, userID int64, key string, hash []byte,	work func(tx *sql.Tx) (status int, body []byte, err error),) (int, []byte, error) {	tx, err := s.db.BeginTx(ctx, nil)	if err != nil {		return 0, nil, err	}	defer tx.Rollback()	res, err := tx.ExecContext(ctx, `		INSERT INTO idempotency_keys (user_id, key, request_hash)		VALUES ($1, $2, $3)		ON CONFLICT (user_id, key) DO NOTHING`, userID, key, hash)	if err != nil {		return 0, nil, err	}	if n, _ := res.RowsAffected(); n == 0 {		// Someone already owns this key. Replay what they stored.		var prev, body []byte		var status int		err := tx.QueryRowContext(ctx, `			SELECT request_hash, status, response			FROM idempotency_keys WHERE user_id = $1 AND key = $2`,			userID, key).Scan(&prev, &status, &body)		if err != nil {			return 0, nil, err		}		if !bytes.Equal(prev, hash) {			return 0, nil, ErrKeyReuse // handler maps this to 422		}		return status, body, nil	}	status, body, err := work(tx) // the order INSERT happens in this same tx	if err != nil {		return 0, nil, err // rollback also frees the key: the client may retry	}	if _, err := tx.ExecContext(ctx, `		UPDATE idempotency_keys SET status = $3, response = $4		WHERE user_id = $1 AND key = $2`, userID, key, status, body); err != nil {		return 0, nil, err	}	return status, body, tx.Commit()}

Why this closes the race: ON CONFLICT DO NOTHING on a row another transaction has inserted but not yet committed waits for that transaction to finish. If it commits, the second request gets zero rows affected and reads the stored response. If it rolls back, the second request inserts the key itself and does the work. Nobody sees a half-finished state, so there is no "in progress" status to invent.

And because the key and the order commit together, you cannot end up with an order and no key, or a key pointing at an order that was never created.

Where it stops working

This only protects what happens inside your own database transaction. If work calls a payment provider, the charge happens outside Postgres, and a rollback cannot take it back. For that call, forward the same key to the provider, since most payment APIs accept their own idempotency key for exactly this reason.

Two smaller things. The transaction stays open while work runs, so keep it short and never hold it across a slow network call. And keys should expire: a nightly DELETE ... WHERE created_at < now() - interval '24 hours' is enough for most clients.

A retry should be boring. The whole point of this table is that the second request finds the first one's answer and has nothing left to decide.