Chapter 07 · MVCC, Transactions, Isolation, Locks, and Serialization
Designing Retryable Transactions, Idempotency, and Serializable Workflows
Design application transactions that retry only transient concurrency failures, preserve idempotency across attempts, bound retries with backoff, and verify business invariants and postconditions under concurrent clients.
Learning outcomes
Serializable isolation is only production-safe when the application accepts that a correct concurrency decision can be “abort this transaction and try the whole business operation again.” Retries introduce a second correctness problem: if a client repeats a request after a timeout or transaction abort, how do you ensure the business effect happens once? ServiceHub will combine SQLSTATE-aware retry boundaries, an idempotency key, state transitions, and postcondition verification.
Classify 40001 serialization_failure and
40P01 deadlock_detected as retry candidates
while refusing to retry every database error.
Explain why PostgreSQL requires retrying the complete transaction, including decision logic and generated values that depend on reads.
Design an idempotency-key table so repeated client requests converge on one durable business result.
Bound retries with attempt limits, jitter/backoff, transaction timeouts, and observability instead of infinite loops.
Prove a Serializable workflow under concurrent clients using business invariants and persisted postconditions rather than “no exception” as the only test.
1. Retryable is not the same as “catch Exception and run again”
PostgreSQL documents 40001 as the canonical
serialization failure and says it is appropriate to retry it
unconditionally. It also says retrying
40P01 deadlocks can be advisable. Other codes—such
as unique or exclusion violations—sometimes arise from
concurrency patterns but can also represent persistent input
bugs, so they require domain-specific classification.
Authentication failures, syntax errors, violated CHECK
constraints, and permission errors are not made correct by
sleeping and trying again.
| SQLSTATE | Meaning | Default policy for this lab |
|---|---|---|
40001 |
serialization_failure | Retry complete transaction with bounded backoff. |
40P01 |
deadlock_detected | Retry complete transaction with bounded backoff. |
55P03 |
lock_not_available | Workflow-specific: often fail fast / retry later, not automatic infinite retry. |
23505 |
unique_violation | Usually business/data outcome; only retry if protocol explicitly interprets it that way. |
42501 |
insufficient_privilege | Do not retry; fix authorization/configuration. |
If your transaction reads balance, chooses an amount, inserts a row, and updates an account, a 40001 retry must repeat the read and the choice. Retrying only the final UPDATE would reuse decisions from a snapshot PostgreSQL already rejected.
2. Build an idempotent transfer/request model
DROP TABLE IF EXISTS app.ch07_transfer_request;DROP TABLE IF EXISTS app.ch07_wallet;CREATE TABLE app.ch07_wallet ( wallet_id integer PRIMARY KEY, owner_name text NOT NULL, balance numeric(12,2) NOT NULL CHECK (balance >= 0));INSERT INTO app.ch07_wallet VALUES(1,'Ava',500.00),(2,'Ben',300.00),(3,'Cara',200.00);CREATE TABLE app.ch07_transfer_request ( request_key text PRIMARY KEY, from_wallet integer NOT NULL REFERENCES app.ch07_wallet(wallet_id), to_wallet integer NOT NULL REFERENCES app.ch07_wallet(wallet_id), amount numeric(12,2) NOT NULL CHECK (amount > 0), status text NOT NULL CHECK (status IN ('processing','completed','rejected')), result_from_balance numeric(12,2), result_to_balance numeric(12,2), created_at timestamptz NOT NULL DEFAULT clock_timestamp(), completed_at timestamptz, CHECK (from_wallet <> to_wallet));
The client supplies request_key and must reuse the
same key when retrying the same semantic request. The primary
key turns “did this request already create a durable
intent/result?” into an atomic database fact.
3. Idempotency starts by claiming the request key
A robust protocol must distinguish “first execution” from “duplicate delivery.” One approach is to insert the request row first. If the key already exists, read and validate the existing request rather than applying the transfer again.
BEGIN ISOLATION LEVEL SERIALIZABLE;INSERT INTO app.ch07_transfer_request(request_key, from_wallet, to_wallet, amount, status)VALUES ('req-20260818-001', 1, 2, 75.00, 'processing')ON CONFLICT (request_key) DO NOTHINGRETURNING request_key;-- If zero rows were returned, fetch the existing request and ensure-- its from/to/amount match the retried semantic request.SELECT request_key, from_wallet, to_wallet, amount, status, result_from_balance, result_to_balanceFROM app.ch07_transfer_requestWHERE request_key = 'req-20260818-001';
If an existing row uses the same key but different amount or endpoints, treat that as an idempotency-key misuse, not as permission to overwrite history.
4. The transfer itself must preserve business invariants
For a simple transfer, lock the participating wallets in deterministic key order to reduce deadlock risk, verify funds, then update both balances. The sum of all wallet balances should remain constant.
-- Continue only for a newly claimed processing request.SELECT wallet_id, balanceFROM app.ch07_walletWHERE wallet_id IN (1,2)ORDER BY wallet_idFOR UPDATE;-- Application checks wallet 1 has at least 75.00.UPDATE app.ch07_walletSET balance = balance - 75.00WHERE wallet_id = 1 AND balance >= 75.00RETURNING balance AS from_balance;-- Require exactly one source row to have been updated.UPDATE app.ch07_walletSET balance = balance + 75.00WHERE wallet_id = 2RETURNING balance AS to_balance;UPDATE app.ch07_transfer_request AS rSET status = 'completed', result_from_balance = (SELECT balance FROM app.ch07_wallet WHERE wallet_id = r.from_wallet), result_to_balance = (SELECT balance FROM app.ch07_wallet WHERE wallet_id = r.to_wallet), completed_at = clock_timestamp()WHERE request_key = 'req-20260818-001'RETURNING status, result_from_balance, result_to_balance;COMMIT;
status | result_from_balance | result_to_balance----------+---------------------+------------------completed | 425.00 | 375.00Total wallet balance remains 1000.00.
The source UPDATE must affect exactly one row. If it affects zero because the balance is insufficient, the application should transition the request to a defined rejected outcome or roll back according to the contract; it should not proceed to credit the destination.
5. Duplicate delivery returns the prior result instead of transferring again
BEGIN ISOLATION LEVEL SERIALIZABLE;INSERT INTO app.ch07_transfer_request(request_key, from_wallet, to_wallet, amount, status)VALUES ('req-20260818-001', 1, 2, 75.00, 'processing')ON CONFLICT (request_key) DO NOTHINGRETURNING request_key;-- Expected: 0 rows, because the key already exists.SELECT request_key, from_wallet, to_wallet, amount, status, result_from_balance, result_to_balanceFROM app.ch07_transfer_requestWHERE request_key = 'req-20260818-001';COMMIT;
A duplicate client request now observes the already completed result. The wallets do not change again. Idempotency is a business protocol, not merely a retry loop.
6. Concurrency: force a Serializable conflict and classify it correctly
To create a simple cross-row invariant, suppose two concurrent
requests each decide whether a maintenance reserve may be moved
based on the total balance across selected wallets. Both
Serializable transactions read the same predicate and then
update different rows. PostgreSQL may reject one history with
40001. The lesson is the retry contract, not a
specific victim.
-- Session ABEGIN ISOLATION LEVEL SERIALIZABLE;SELECT sum(balance) FROM app.ch07_wallet WHERE wallet_id IN (1,2,3);UPDATE app.ch07_wallet SET balance = balance - 10 WHERE wallet_id = 1;-- Session BBEGIN ISOLATION LEVEL SERIALIZABLE;SELECT sum(balance) FROM app.ch07_wallet WHERE wallet_id IN (1,2,3);UPDATE app.ch07_wallet SET balance = balance - 10 WHERE wallet_id = 2;-- Add corresponding business-state writes as needed, then COMMIT both.-- Depending on the complete dependency structure, PostgreSQL can reject one-- transaction with SQLSTATE 40001. Do not hard-code which session loses.
A single pair of reads/writes is not guaranteed to generate the exact same serialization failure on every plan/interleaving. Serializable failures are dependency-graph outcomes. Use the deterministic write-skew experiment from Lesson 2 when you need a reliable classroom demonstration, and use this section to practice error classification and retry design.
7. Application retry pseudocode: classify, bound, restart everything
The driver must expose SQLSTATE (often called SQL state or server error code). Match structured codes, not English error strings. Each attempt starts a new transaction and recomputes all decisions. Backoff should be bounded and can include jitter to avoid synchronized clients immediately colliding again.
MAX_ATTEMPTS = 5RETRYABLE = {"40001", "40P01"}for attempt in range(1, MAX_ATTEMPTS + 1): try: with new_transaction(isolation="serializable") as tx: # Re-read everything inside THIS attempt. request = claim_or_load_idempotency_key(tx, request_key, payload) if request.status == "completed": return request.result result = execute_business_rules(tx, request) persist_result(tx, request_key, result) tx.commit() return result except DatabaseError as exc: sqlstate = exc.sqlstate if sqlstate not in RETRYABLE: raise if attempt == MAX_ATTEMPTS: raise RetryBudgetExhausted() from exc sleep(bounded_backoff_with_jitter(attempt))raise AssertionError("unreachable")
This is deliberately not tied to psycopg, JDBC, Npgsql, node-postgres, or another driver API. In real code use the driver's documented SQLSTATE field, transaction lifecycle, cancellation, and connection-pool behavior.
A retry budget converts unbounded contention into a visible operational failure. Record attempt count, final SQLSTATE, transaction duration, request key, and relevant workload identifiers without leaking secrets.
8. Timeout and side-effect boundaries
Database retries become dangerous when a transaction performs nontransactional external effects such as sending email, charging an external payment gateway, or commanding equipment. If the database transaction later aborts, PostgreSQL cannot roll those external effects back. Use an outbox/state-machine pattern or another durable protocol so external work is driven from committed state and is itself idempotent.
Keep transactions short. Configure client/server statement and lock timeouts appropriate to the request, and ensure the client can distinguish timeout/cancellation from connection-loss ambiguity. An unknown commit outcome is a different problem from a known 40001 rollback; the idempotency key is what lets the client safely ask, “did request X already complete?”
9. Prove postconditions, not just successful COMMIT
SELECT wallet_id, owner_name, balanceFROM app.ch07_walletORDER BY wallet_id;SELECT sum(balance) AS total_balanceFROM app.ch07_wallet;SELECT request_key, from_wallet, to_wallet, amount, status, result_from_balance, result_to_balance, completed_atFROM app.ch07_transfer_requestORDER BY request_key;SELECT request_key, count(*)FROM app.ch07_transfer_requestGROUP BY request_keyHAVING count(*) > 1;-- Last query must return 0 rows because request_key is the primary key.
Ava: 425.00Ben: 375.00Cara: 200.00Total: 1000.00Exactly one request row exists for req-20260818-001 and status = completed.
10. Cleanup and chapter synthesis
ROLLBACK;DROP TABLE IF EXISTS app.ch07_transfer_request;DROP TABLE IF EXISTS app.ch07_wallet;
Check your understanding
- Why must a 40001 retry repeat decision logic, not just the failed statement?
- Why should an idempotency key belong to the client-visible semantic request?
- Should every 23505 unique violation be retried automatically?
- Why is a bounded retry budget part of correctness/operations rather than merely performance tuning?
- What protects against repeating an external side effect after a database retry?
Review the answers
40001 means PostgreSQL rejected the prior transactional history/snapshot; all reads and choices must be recomputed in a new transaction. The key identifies one semantic request across transport/database retries. A 23505 can be persistent bad input, so retry it only when a specific protocol classifies it as transient/concurrent. Bounded retries prevent infinite loops and surface contention as an observable failure. External effects require their own durable idempotency/state protocol, such as an outbox-driven design, because PostgreSQL cannot roll back an external system.
11. Production judgment and bridge to Chapter 08
Concurrency correctness is a joint database/application contract. PostgreSQL supplies MVCC, isolation, locks, deadlock detection, Serializable conflict detection, SQLSTATEs, constraints, and transactional atomicity. Applications must supply retry scope, idempotency, request identity, bounded backoff, external-side-effect discipline, and invariant verification. Chapter 08 descends from logical concurrency into heap pages, tuple headers, TOAST, HOT updates, bloat, and physical evidence—the storage machinery that makes many of Chapter 07's behaviors possible.