A race condition in a payment system happens when two requests read the same balance at the same time, both decide there is enough money, and both write. Money appears, disappears, or a balance goes negative when your rules say it can't. The fix is rarely a mutex in application code. It's letting the database decide, in one place, whether each change is allowed.
This is a practical playbook: the classic double-spend bug, database-level fixes in order of preference, and idempotency keys for requests that arrive twice. Examples use PostgreSQL 16 and Go with pgx v5.
The double-spend race condition in a payment flow
Most of these bugs come from the same shape: read, check in the application, then write.
SELECT balance FROM accounts WHERE id = $1; -- app: if balance < amount, reject
UPDATE accounts SET balance = $2 WHERE id = $1; -- $2 = balance - amount, computed in the appThe account holds 100. Two withdrawals of 80 arrive a few milliseconds apart:
| Step | Request A | Request B |
|---|---|---|
| 1 | reads balance = 100 | |
| 2 | reads balance = 100 | |
| 3 | 100 >= 80, OK | 100 >= 80, OK |
| 4 | writes balance = 20 | |
| 5 | writes balance = 20 |
The customer received 160 and the account shows 20. Writing balance = balance - $2 instead leaves it at -60. Either way, the check used a value that was stale by the time of the write.
A transaction alone doesn't help at the default Read Committed level, because a plain SELECT takes no lock. An in-process mutex fails as soon as you run two instances. The database is what every instance shares, so the decision belongs there.
Fix 1: an atomic conditional UPDATE
Collapse read, check and write into one statement, and let the database evaluate the condition while it holds the row lock:
UPDATE accounts
SET balance = balance - $2,
updated_at = now()
WHERE id = $1
AND balance >= $2
RETURNING balance;If no row comes back, the funds weren't there (in pgx, Scan returns pgx.ErrNoRows).
Why this works at Read Committed: the first UPDATE locks the row. The second waits for it, and once the first commits, PostgreSQL re-evaluates balance >= $2 against the new version of the row (now 20). The condition fails and zero rows are updated. No retry loop is needed. (At Repeatable Read, the second statement fails with a serialization error instead, so stay at Read Committed for this pattern.)
Add a constraint as a safety net for any code path that forgets the condition:
ALTER TABLE accounts ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);Store money as integer minor units or numeric, never floating point, and write the ledger entry in the same transaction. Use this whenever the rule fits in one row's WHERE clause, which covers most debits.
Fix 2: pessimistic locking with SELECT ... FOR UPDATE
When the decision needs more than arithmetic, such as fees, daily limits or risk rules, lock the row first:
BEGIN;
SELECT balance, daily_limit, spent_today
FROM accounts
WHERE id = $1
FOR UPDATE; -- other writers to this row now wait
-- application logic: fees, limits, risk rules
UPDATE accounts SET balance = $2, spent_today = $3 WHERE id = $1;
INSERT INTO ledger_entries (account_id, amount, kind) VALUES ($1, $4, 'debit');
COMMIT;For transfers, lock both accounts in a consistent order so two opposite transfers can't deadlock:
SELECT id, balance FROM accounts
WHERE id IN ($1, $2)
ORDER BY id
FOR UPDATE;Keep the locked section short, never call a payment gateway or another service while holding the lock, and run SET LOCAL lock_timeout = '2s' so a stuck transaction fails fast instead of piling up connections.
Fix 3: optimistic locking with a version column
If conflicts are rare, or the read and the write happen in different requests (a user edits payout settings in a form), don't hold a lock. Detect the conflict at write time instead:
ALTER TABLE wallets ADD COLUMN version bigint NOT NULL DEFAULT 0;
-- read
SELECT balance, version FROM wallets WHERE id = $1;
-- write only if nobody changed the row since we read it
UPDATE wallets
SET balance = $2, version = version + 1
WHERE id = $1 AND version = $3;Zero rows updated means someone else got there first: reload and retry, or return 409 Conflict. Under heavy contention this becomes a retry storm, and Fix 1 or 2 is the better choice.
Fix 4: rules that span several rows
The fixes above protect one row. Some rules involve several, such as "savings plus checking must not go below zero". Two transactions can each check the rule, write different rows, and together break it. That anomaly is write skew. The theory, and how PostgreSQL's Serializable level detects it, is in transaction isolation levels and write skew. In practice you have four options.
Materialize the conflict into one row. Keep the customer-level total in its own row and make every withdrawal update it with an atomic condition. Now both transactions hit the same row, and Fix 1 applies again:
UPDATE customer_balances
SET total = total - $2
WHERE customer_id = $1 AND total >= $2;
-- 0 rows: reject. Otherwise update the individual account in the same transaction.Lock a parent row. Lock the customer before reading anything, so every operation for that customer runs one at a time:
BEGIN; -- Read Committed
SELECT 1 FROM customers WHERE id = $1 FOR UPDATE;
SELECT sum(balance) FROM accounts WHERE customer_id = $1;
-- check the rule, then update one account
COMMIT;This relies on Read Committed: each statement after the lock takes a fresh snapshot, so the sum sees what the previous holder committed. At Repeatable Read the snapshot comes from the first statement and could be stale.
Use a constraint. If the rule can be expressed declaratively, the database enforces it regardless of timing:
-- at most one pending payout per account
CREATE UNIQUE INDEX one_pending_payout
ON payouts (account_id)
WHERE status = 'pending';Use SERIALIZABLE for the transactions involved, with a retry loop on SQLSTATE 40001. It's the most general option and the one with the most runtime cost.
Idempotency keys: preventing duplicate payments
Everything above stops two different requests from overdrawing an account. It doesn't stop the same request, retried after a timeout, double-clicked or redelivered by a broker, from being applied twice.
The fix is an idempotency key. The client generates a unique key per logical operation (a UUID), sends it with every attempt, and the server records it in the same transaction as the money movement:
CREATE TABLE idempotency_keys (
client_id bigint NOT NULL,
key text NOT NULL,
request_hash text NOT NULL,
response jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (client_id, key)
);func (s *Service) Withdraw(ctx context.Context, req WithdrawRequest) (Result, error) {
var res Result
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
INSERT INTO idempotency_keys (client_id, key, request_hash)
VALUES ($1, $2, $3)
ON CONFLICT (client_id, key) DO NOTHING`,
req.ClientID, req.IdempotencyKey, req.Hash())
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errAlreadyProcessed
}
err = tx.QueryRow(ctx, `
UPDATE accounts SET balance = balance - $2
WHERE id = $1 AND balance >= $2
RETURNING balance`, req.AccountID, req.Amount).Scan(&res.Balance)
if errors.Is(err, pgx.ErrNoRows) {
return ErrInsufficientFunds
}
if err != nil {
return err
}
_, err = tx.Exec(ctx, `
UPDATE idempotency_keys SET response = $3
WHERE client_id = $1 AND key = $2`,
req.ClientID, req.IdempotencyKey, res)
return err
})
if errors.Is(err, errAlreadyProcessed) {
return s.storedResult(ctx, req) // compare request_hash, return saved response
}
return res, err
}Details that matter:
- Concurrent duplicates are handled by the primary key. The second
INSERTwaits for the first transaction, then sees the conflict. - Compare the request hash. The same key with a different amount is a client bug. Reject it.
- Declines roll back with the transaction in this sketch, so a retry is evaluated again. If you need to replay declines too, store that outcome in a separate committed write.
- Pass keys downstream. Most payment providers accept an idempotency key, so reuse one per charge on every retry. Webhook and queue consumers should deduplicate on the event ID.
- Expire old keys only after every client's retry window has passed.
Retries at the network layer are covered further in resilient microservice communication.
Choosing a strategy to prevent a payment race condition
| Situation | Strategy | Trade-off |
|---|---|---|
| Rule fits one row (sufficient balance) | Atomic conditional UPDATE + CHECK | Simplest and fastest. Logic must fit in SQL |
| Complex logic on one or two rows | SELECT ... FOR UPDATE, consistent lock order | Blocks concurrent writers. Keep it short |
| Low contention, read and write in separate requests | Optimistic locking with version | Retries under contention |
| Rule spans rows, one hot parent | Materialized total or parent-row lock | Serializes that parent's operations |
| Rule is declarative | UNIQUE, partial index, EXCLUDE, CHECK | Only fits some rules |
| Complex rule spanning many rows | SERIALIZABLE + retry | Retry code, lower throughput |
| Same request arriving twice | Idempotency key in the same transaction | Extra table and client cooperation |
FAQ
What is a race condition in a payment system?
Concurrent operations read the same balance, each decides based on it, and their writes combine into a wrong result such as a double withdrawal.
How do you prevent double spending in a database?
Make the check and the write one atomic operation: UPDATE ... WHERE balance >= amount, a row lock with SELECT ... FOR UPDATE, or a version check. Add a CHECK constraint and idempotency keys so retries don't charge twice.
Is pessimistic or optimistic locking better for payments?
For busy balances, an atomic update or pessimistic lock, since optimistic locking retries a lot under contention. Optimistic locking suits rarely contended data.
Do I need SERIALIZABLE isolation for payments?
Not for single-row rules. Atomic updates and row locks at Read Committed are enough. Consider Serializable for rules that span several rows and can't be handled by a materialized row, a parent lock or a constraint.
Does an idempotency key prevent race conditions?
It prevents the same request from being applied twice. It doesn't stop two different requests from overdrawing an account. You need both.
Checklist
- No read-check-write in application code for balances
- Every debit is an atomic conditional
UPDATEor runs under a row lock -
CHECK (balance >= 0)or an equivalent constraint exists - Transfers lock rows in a consistent order, with no external calls under lock
- Every multi-row rule has a named protection mechanism
- Every money-moving endpoint requires an idempotency key, recorded in the same transaction
If you're building or auditing a payment flow and want experienced help, Vectorkub designs and builds backend systems like this.
