Concurrency, Locking, and Idempotency

Financial and inventory data has correctness requirements that ordinary web applications don’t. This page is the set of patterns that keep it correct under concurrent load.

The three problems

  1. Lost update — two users edit the same record; one overwrites the other
  2. Over-commitment — two orders confirm the same stock
  3. Double posting — a retried message posts the same invoice twice

Each has a distinct solution. Confusing them produces systems that are slow and incorrect.

Optimistic vs pessimistic locking

Optimistic (default for user editing)

UPDATE sales_order
   SET quantity = 500, version = version + 1
 WHERE id = 4711 AND version = 7;
-- 0 rows affected → someone else changed it → show a conflict message

Cheap, no held locks, works across stateless requests. Best for records edited rarely and concurrently seldom. The cost is a user occasionally losing work — which is why the conflict message must show what changed, not just “please retry”.

Pessimistic (for genuine contention)

SELECT * FROM material_valuation
 WHERE material_id = 100 AND plant_id = 1
   FOR UPDATE;

Necessary where the read-modify-write must be atomic: balances, moving-average price, number ranges, stock. Hold it for the shortest possible time, and never across a user interaction.

Application-level locks (the ERP pattern)

For long-running user edits, ERPs hold a lock in an application lock table, not in the database:

CREATE TABLE app_lock (
  lock_key    VARCHAR(200) PRIMARY KEY,   -- 'SALES_ORDER:4711'
  owner       VARCHAR(100) NOT NULL,      -- session/user
  acquired_at TIMESTAMPTZ NOT NULL,
  expires_at  TIMESTAMPTZ NOT NULL
);
User opens order 4711  → INSERT app_lock (fails if held → "locked by Karim")
User edits for 8 min   → no DB lock held
User saves             → short DB transaction: validate, write, commit
                       → DELETE app_lock

Requirements: TTL expiry (users close laptops), an admin unlock with logging, lock granularity that matches how people actually work (locking a whole customer master when two people edit different sales areas is too coarse).

Number range assignment

The gapless requirement forces serialisation. See Number Ranges and Keys.

BEGIN;
  SELECT current_number FROM number_range
   WHERE object='AR_INVOICE' AND company_id=1000 AND fiscal_year=2026
   FOR UPDATE;                                  -- serialisation point
  UPDATE number_range SET current_number = current_number + 1 WHERE ...;
  INSERT INTO ar_invoice (document_number, ...) VALUES (:new_number, ...);
COMMIT;

Assign the number as late as possible. If you take the lock at the start of building a 500-line invoice, every other invoice in the company queues behind you for the whole build.

MAX(number)+1 is not an alternative. It races, deadlocks, and degrades with table size.

Stock and ATP concurrency

Stock 100.
Order A checks: 100 available → confirms 80
Order B checks: 100 available → confirms 80    ← checked before A committed
Both save → 160 confirmed, 100 exist.

Four legitimate strategies:

Strategy Correctness Throughput Where
Lock the material/plant for check+confirm Perfect Bottleneck on hot items High-value, low-volume
Optimistic: re-check at save, fail if over Perfect Good General purpose
Reservation: check creates a short-lived hold Perfect Good E-commerce
Accept over-commitment, resolve in backorder Eventually Best High-volume consumer goods

This is a business decision, not a technical one. Ask: is disappointing a customer occasionally worse than slowing order entry for everyone? Document the answer.

Balance updates

Every posting to the same account/period updates the same balance row → contention.

-- Instead of UPDATE balance SET amount = amount + 500
INSERT INTO balance_delta (account_id, period, amount) VALUES (100, 202603, 500);

-- Read: SELECT SUM(amount) FROM balance_delta WHERE ...
-- Compaction job periodically rolls deltas into a summary row

Converts an UPDATE hotspot into an INSERT. Scales dramatically better.

Pattern 2: sharded counters

INSERT INTO balance_shard (account_id, period, shard, amount)
VALUES (100, 202603, floor(random()*16), 500)
ON CONFLICT (account_id, period, shard) DO UPDATE SET amount = balance_shard.amount + 500;

16 shards → 16× less contention. Sum across shards on read.

Pattern 3: asynchronous aggregation

Post the journal; a background process updates balances. Fast writes, eventually-consistent reads. Acceptable for reporting, not acceptable for a real-time availability or budget check.

Deadlocks

Transaction A: locks customer 100, then order 4711
Transaction B: locks order 4711, then customer 100
→ deadlock

Prevention:

  1. Always acquire locks in a consistent order — sort by table then by primary key. This one rule eliminates most deadlocks.
  2. Keep transactions short.
  3. Take the most contended lock last.
  4. Retry on deadlock with jittered backoff — deadlocks are expected, not exceptional.
  5. Log every deadlock with both statements; the pattern reveals the design flaw.

Idempotency

The most important pattern for ERP integration. Networks time out. The caller doesn’t know if the posting succeeded. It retries. Without idempotency you post twice — and a duplicated invoice or payment is a real financial event.

CREATE TABLE idempotency_key (
  key            VARCHAR(200) PRIMARY KEY,
  request_hash   VARCHAR(64) NOT NULL,
  response       JSONB,
  status,        -- IN_PROGRESS | COMPLETED | FAILED
  created_at, expires_at
);
POST /gl/documents
Idempotency-Key: goods-receipt-4500001-line-10-v1

1. INSERT idempotency_key (key, hash, 'IN_PROGRESS')
     ON CONFLICT DO NOTHING
2. If 0 rows inserted:
     - existing status COMPLETED → return the stored response (200, not a new posting)
     - existing status IN_PROGRESS → return 409, tell the caller to retry later
     - request_hash differs → return 422: same key, different payload
3. Otherwise: perform the work, store the response, mark COMPLETED

Choosing the key

Good:  source system + source document + line + operation
       "WMS|SHIP-88213|10|GOODS_ISSUE"
       "BANK|STMT-2026-03-14|LINE-42|POST"
Bad:   a random UUID generated per attempt  ← a retry generates a new key
Bad:   a timestamp

The key must be derived from the business event, so a retry produces the same key. This is the part teams get wrong: a client that generates a fresh UUID on each retry has idempotency machinery that does nothing.

Natural idempotency

Better still, use a unique constraint on the business key:

ALTER TABLE ap_invoice
  ADD CONSTRAINT uq_vendor_invoice UNIQUE (company_id, vendor_id, invoice_number);

The database refuses the duplicate. Cheap, permanent, and it also catches duplicates entered by humans.

Exactly-once processing

True exactly-once delivery doesn’t exist across a network. What you can build is at-least-once delivery plus idempotent processing, which is operationally equivalent.

Producer:  transactional outbox      (never lose an event, never publish a phantom)
Broker:    at-least-once delivery
Consumer:  idempotent handler        (safe to process the same message twice)
Result:    effectively exactly-once

Read consistency

Level Meaning ERP use
Read committed See committed data Default; fine for most
Repeatable read Same data throughout the transaction Reports needing internal consistency
Serializable As if transactions ran one at a time Critical financial calculations

A trial balance that reads accounts one at a time under read-committed can see account A before a posting and account B after it — and won’t balance. Use a consistent snapshot for anything that must foot.

BEGIN ISOLATION LEVEL REPEATABLE READ;
  -- all reads see the same snapshot
COMMIT;

Long-running transactions

❌ BEGIN; process 5,000,000 records; COMMIT;

Blocks cleanup, holds locks for hours, makes rollback catastrophic, and one failure loses everything.

✅ for each chunk of 5,000:
     BEGIN; process chunk; update checkpoint; COMMIT;

See Jobs, Queues and Batch.

Race conditions to watch for

Race Fix
Two users create the same customer Unique constraint on tax ID / normalised name
Two orders confirm the same stock Choose an ATP strategy (above)
Two payments against one invoice Lock the open item during clearing
Two approvals of the same request Unique constraint on (instance, step, approver)
Two batch jobs running concurrently Advisory lock or a unique run-registry constraint
Budget check passes for two POs that together exceed Serialise the check+write on the budget key
Two goods receipts against the same PO line beyond tolerance Lock the PO line

Testing concurrency

Concurrency bugs don’t appear in single-user testing. Ever.

- Load test with realistic concurrency, not one user
- Deliberately run two clients against the same record
- Introduce artificial delays between read and write to widen the race window
- Chaos test: kill a process mid-transaction
- Replay the same message 3× and assert the effect is identical
- Run the batch job twice and diff the results

“It worked in testing” means nothing for concurrency. The bugs surface at month-end under load, in production, at 2am.

Checklist

  • Optimistic locking (version column) on all user-editable entities
  • Application lock table for long user edits, with TTL
  • Number range assignment serialised and as late as possible
  • Balance updates use delta rows or sharded counters
  • Consistent lock ordering documented and followed
  • Deadlock retry with backoff
  • Idempotency key on every write API, derived from the business event
  • Unique constraints on natural business keys
  • Transactional outbox for events
  • Snapshot isolation for reports that must balance
  • Batch jobs chunk their commits
  • Concurrency explicitly tested

Next