Topic 30

Isolation Levels for Real

Isolation Levels

Postgres accepts all four standard isolation levels and implements three of them. Choosing between those three is an engineering decision with measurable costs on both sides: a level that is too low loses writes that go unnoticed for months, and a level that is too high turns a contention spike into user-visible errors. Neither failure shows up in single-threaded testing.

This is where the strawberries get settled. One box, two confirmations, one apology on Monday: the March incident has three correct fixes in Postgres, and they cost three very different things. All three appear here, applied to Cartwheel's actual checkout, and one of them ships.

Three levels: when the snapshot is taken, and how each one fails
Read Committedthe default
A fresh snapshot per statement. Dirty reads are impossible and an ordinary UPDATE never fails with a serialization error — it waits, then re-checks its WHERE clause against the row version left behind. What it does not offer is a stable view across statements.
Repeatable Readone snapshot
One snapshot for the whole transaction. Repeated reads agree, and phantom reads do not occur either. A write to a row that moved since the snapshot is refused with 40001, not merged or partially applied.
Serializableplus monitoring
Repeatable Read plus dependency tracking. One transaction is aborted when the set of them could not have been produced by running them one at a time in any order. The cost is bookkeeping and aborts, not waiting — and the guarantee holds only among Serializable transactions.

Read Committed

The default takes a fresh snapshot for every statement. Each query sees data committed before that query began, dirty reads are impossible, and an ordinary UPDATE never fails with a serialization error — it waits for the transaction ahead of it and then re-checks its WHERE clause against the row version that transaction left behind. For the vast majority of short OLTP work that is exactly right, and it is the level under which Cartwheel's 3,000 checkouts a minute should keep running.

What it does not offer is a stable view across statements. Read the same row twice and it may have changed; count matching rows twice and the count may move. That is precisely the property the strawberries relied on. It is also what makes the level cheap: no dependency tracking, no retries, no snapshot held longer than one statement needs it.

Repeatable Read

One snapshot is taken at the transaction's first statement and used until it ends. Repeated reads agree, and Postgres goes further than the standard requires: phantom reads do not occur either, so a query re-run inside the transaction cannot pick up rows another transaction inserted. What the level does not prevent is a serialization anomaly across transactions, and it enforces its consistency on writes by refusing them.

A write to a row that moved since the snapshot is refused, not merged
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT on_hand FROM inventory
 WHERE product_id = 4471 AND warehouse_id = 2;   -- 1

-- another session decrements this row and commits

UPDATE inventory SET on_hand = on_hand - 1
 WHERE product_id = 4471 AND warehouse_id = 2;
-- ERROR:  could not serialize access due to concurrent update
ROLLBACK;

The transaction is not partially applied and it is not silently corrected. It is aborted, and the application's only correct response is to replay the whole thing from the beginning against a fresh snapshot. The trade the level exists to make is exactly that replacement: an error you have to handle instead of a wrong number you will not notice. Under Read Committed the same pair of statements would have succeeded and written a value computed from a stale read.

Serializable

Serializable is Repeatable Read plus monitoring. Postgres tracks the read/write dependencies between concurrent Serializable transactions and aborts one when the set of them could not have been produced by running them one at a time in any order. The technique is called serializable snapshot isolation, and the tracking structures show up in pg_locks with a mode of SIReadLock. Those predicate locks block nothing and cannot take part in a deadlock. The cost is bookkeeping and aborted transactions, not waiting.

Scope is the first limit on where the level fits: the guarantee holds across a set of Serializable transactions, so a concurrent Read Committed session is not part of the set and is not held to it. Raising one transaction to Serializable protects nothing if the transaction it races against was left at the default. The second limit is the cost of coarse tracking. Predicate locks start fine-grained and are combined into coarser ones when the tracking table runs short of memory, and a sequential scan always takes a relation-level predicate lock — either of which raises the abort rate sharply. A Serializable transaction that seq-scans orders is a Serializable transaction that conflicts with every writer on the table.

There is one more honest caveat in the documentation: the level does not guarantee that no error other than a serialization failure occurs. Overlapping Serializable transactions can still produce a unique-constraint violation, even when each of them checked for the key first. A handler that only knows about 40001 will meet 23505 anyway.

Retry Is Part of the Contract

At Repeatable Read and Serializable, 40001 is not an error condition to log and move past. It is the level working as designed, and the application owes it a bounded retry of the entire transaction, at the outermost layer where every statement can be replayed. Every serialization failure carries that same SQLSTATE regardless of which of the two messages it prints, which makes the handler easy to write and easy to forget.

The shape of a correct retry, wrapped around the whole transaction
for attempt in 1..5:
    try:
        BEGIN ISOLATION LEVEL SERIALIZABLE;
          -- every statement of the business transaction, replayed
        COMMIT;
        break
    except SQLSTATE '40001' or '40P01':
        ROLLBACK;
        sleep(random(0, 50ms * 2 ** attempt))   -- backoff with jitter
raise -- five failures is a contention problem, not a retry problem

That sketch has three load-bearing details. The retry wraps the transaction rather than the failing statement, because a partially executed transaction is gone and its reads cannot be trusted. The backoff carries jitter, because synchronized retries reproduce the contention that caused the abort. And the attempt count is bounded, because an unbounded retry loop turns a hot row into an outage that looks like a hung application. Deadlocks, which arrive as 40P01, belong in the same handler for the same reason.

Fixing the Strawberries, Three Ways

The failing checkout reads on_hand, decides in Python, and writes a constant. The constraint Chapter 3 put on that column is not one of the answers: CHECK (on_hand >= 0) refuses a negative number, and both of these checkouts wrote zero. Three fixes remove the gap itself, and they are not interchangeable — each buys correctness with a different currency.

Fix (a): one guarded statement, and the row count is the answer
UPDATE inventory
   SET on_hand = on_hand - 1
 WHERE product_id = 4471
   AND warehouse_id = 2
   AND on_hand > 0;

-- UPDATE 1  -> stock was there, the sale is confirmed
-- UPDATE 0  -> no stock, refuse the sale and tell the customer

This runs at Read Committed and needs nothing else. The previous topic supplied the mechanism — one statement, one snapshot, and a loser that waits and then re-evaluates its guard against the version the winner committed. What is new here is what the application is left holding. It gets a row count of zero rather than an exception, so the out-of-stock path is a test on the affected-row count instead of a handler, and there is no retry loop to write. The business rule moved from application memory into the WHERE clause, where the engine can enforce it, and the whole change is one predicate plus that test.

Fix (b): claim the row first, decide second
BEGIN;
SELECT on_hand FROM inventory
 WHERE product_id = 4471 AND warehouse_id = 2
   FOR UPDATE;          -- a second session blocks right here

-- the application decides, over the network, with the lock held

UPDATE inventory SET on_hand = on_hand - 1
 WHERE product_id = 4471 AND warehouse_id = 2;
COMMIT;                 -- the row lock is released here, not before

This is explicit and easy to reason about: the contenders serialize at the SELECT, and the second session reads the value the first one left. It is also the expensive option, because the lock is held for the entire application round trip. At 3,000 orders a minute, 40 ms of Python and one network hop per session turn the most popular product's row into a queue that every other checkout for that product stands in. Choose it when the decision genuinely cannot be expressed as arithmetic, a rule that consults a pricing service being the usual example, and keep everything between the lock and the commit inside the database.

Fix (c): let the engine detect what no single statement can lock
BEGIN ISOLATION LEVEL SERIALIZABLE;

-- how many orders are already booked into this delivery window,
-- and how many couriers are on shift to carry them
SELECT count(*) FROM orders
 WHERE placed_at >= '2026-08-15 18:00+02'
   AND placed_at <  '2026-08-15 20:00+02';
SELECT count(*) FROM courier_shifts
 WHERE shift @> '2026-08-15 19:00+02'::timestamptz;

INSERT INTO orders (customer_id, placed_at, status, total)
VALUES (88213, '2026-08-15 19:12+02', 'pending', 41.90);
COMMIT;
-- ERROR:  could not serialize access due to read/write
--         dependencies among transactions      (SQLSTATE 40001)

Nothing in that transaction can be collapsed into one statement, and there is no single row to lock: the invariant is a count over a set of rows that other transactions are inserting into at the same time. Serializable is the general answer to that shape. Both sessions read the same window, both insert into it, and one of them is aborted because no serial order of the two produces the result they both assumed. The retry replays it, the second attempt reads the committed count, and the capacity rule holds without a single explicit lock.

Cartwheel ships fix (a). Checkout stays at Read Committed, the decrement carries its own guard, and the application reads the affected-row count instead of a stock level. Fix (b) was rejected on throughput at Saturday peak, and fix (c) was kept for the delivery-window capacity rule, where it earns its retries. The March incident is now a row count of zero and an out-of-stock message on the second customer's screen, and the two-line diff that produced that outcome is the smallest change in this book with the largest number of confirmations behind it.

Three fixes for one gap, and the currency each one spends
The decision is arithmetic on the row, so read, test and write collapse into one statement(a) A guarded UPDATE
The decision genuinely cannot be expressed as arithmetic and needs an application round trip(b) SELECT … FOR UPDATE
The invariant is a count over rows that no single statement can lock(c) Serializable + retry

Choosing per Transaction

The level is a property of a transaction, not of a database. BEGIN ISOLATION LEVEL REPEATABLE READ sets it for one transaction, SET TRANSACTION ISOLATION LEVEL does the same before the first query, and ALTER ROLE cartwheel_analytics SET default_transaction_isolation = 'repeatable read' makes a whole role's transactions consistent by default. A mixed setting is not a smell — checkout at Read Committed, the nightly reconciliation at Serializable, the dashboard's multi-query report at Repeatable Read is three correct decisions, not one indecisive one.

The mix needs two habits to stay safe. Write down which transactions participate in each invariant, because raising one side of a race to Serializable while the other stays at the default leaves the race where it was and charges for the tracking anyway. And declare a transaction READ ONLY when it writes nothing: a read-only Serializable transaction can often establish that it cannot cause an anomaly and stop tracking dependencies entirely.

The Three Levels, Priced

Read Committed — a fresh snapshot per statement, no dependency tracking, no serialization failures on ordinary writes. Use it for short OLTP work and for anything whose read-modify-write fits in one guarded statement, which is most of checkout.

Repeatable Read — one snapshot for the whole transaction, so multi-query reports agree with themselves, and writes to rows that moved are refused with 40001. Use it for reports that must be internally consistent and for read-modify-write on a single row that needs a retry anyway.

Serializable — dependency tracking on top of that, correct for invariants spanning rows no statement can lock, with more aborts under contention and a guarantee that holds only among Serializable transactions. Use it with a bounded retry loop, or do not use it.

Common Mistakes
  • Deploying Serializable without a retry loop — the first contention spike surfaces as 40001 errors in the user's face, and the team concludes the level is broken rather than unfinished.
  • Expecting a Serializable transaction to be protected from a concurrent Read Committed one — the guarantee holds across Serializable transactions only, so both sides of an invariant must be raised.
  • Reaching for LOCK TABLE inventory to stop the double sale — it serializes every product's checkout on one lock and converts a correctness bug into a throughput outage.
  • Treating SELECT … FOR UPDATE as free — the row lock is held until commit, so any application think-time inside the transaction is time every other session spends queueing.
  • Setting default_transaction_isolation to serializable cluster-wide to be safe — every trivial query now pays for dependency tracking, and transactions with no invariant to protect start aborting.
  • Retrying the failed statement instead of the transaction — the reads that led to it are already invalid, so the replay produces a decision built on data the abort said not to trust.
Best Practices
  • Solve single-row read-modify-write with one guarded statement and a row-count check first, and escalate only when the invariant genuinely spans rows.
  • Set the isolation level per transaction from what that transaction must guarantee, and per role for workloads whose transactions all want the same thing.
  • Wrap every Repeatable Read and Serializable transaction in a bounded retry with jitter, placed where the whole transaction can be replayed, and handle 40P01 there too.
  • Declare transactions READ ONLY when they write nothing, so a Serializable reader can drop its predicate tracking as soon as it proves it is safe.
  • Test contention deliberately, with two sessions against the same key run a thousand times, because none of these defects appear in a single-threaded test suite.
  • Keep an index on the columns a Serializable transaction filters by, since a sequential scan takes a relation-level predicate lock and raises the abort rate for everyone.
Comparable toolsOracle Serializable is snapshot isolation, and permits write skewInnoDB Repeatable Read by default, enforced with gap locksSQL Server SERIALIZABLE via range locks, SNAPSHOT as a separate levelCockroachDB Serializable by default, with retries in the client driver

Knowledge Check

Checkout is raised to SERIALIZABLE, but the warehouse feed still runs at the default. What does the change buy?

  • Full protection, because the stricter of the two isolation levels governs both of the transactions
  • Nothing against the feed, because the guarantee holds only among Serializable transactions
  • Protection, because Postgres promotes the conflicting transaction to match
  • An error at BEGIN, because mixed isolation levels are rejected outright

Why does the guarded single-statement decrement work correctly under Read Committed?

  • The UPDATE locks the whole inventory table for the length of the statement
  • A statement with a WHERE guard is executed at the Serializable level
  • The waiting statement re-checks its guard against the newly committed version
  • The second session gets a serialization failure that the driver retries for it

A transaction at Repeatable Read updates a row that another session changed after its snapshot. What happens?

  • It aborts with a serialization failure, and the transaction must be retried
  • It waits, re-reads the new version, and applies the change to that version
  • It silently writes over the newer version using the value from its own snapshot
  • It reports zero rows affected and lets the transaction continue normally

Which situation genuinely calls for SERIALIZABLE rather than a guarded single statement?

  • Decrementing one inventory row while refusing to take it below zero
  • Capping the orders booked into a delivery window against couriers on shift
  • Inserting a delivery event that a retrying producer may send twice
  • Summing yesterday's order totals for the dashboard in one aggregate query

What is the real cost of fixing the double sale with SELECT ... FOR UPDATE?

  • It does not actually prevent the second sale under concurrent checkouts
  • The row lock is held across the application round trip, so contenders queue
  • The lock table fills once a few thousand rows are locked at the same time
  • Plain SELECT statements on the same table block until the lock is released

You got correct