System Design
T2

Isolation levels and anomalies

Two transactions run at the same time. Each one is correct alone. Together they can read or write a state that no serial order gives. Here is what Postgres prevents at each level, and how to fix the rest.

Not startedSaved in this browser only.
  1. 1Postgres has no dirty reads at any level. READ UNCOMMITTED acts as READ COMMITTED.
  2. 2READ COMMITTED reads a new snapshot per statement: lost updates, read skew and write skew get through.
  3. 3REPEATABLE READ is snapshot isolation. It stops phantoms and lost updates, but not write skew.
  4. 4Default to READ COMMITTED with targeted locks. Use SERIALIZABLE when an invariant spans rows and contention is low.
T2
    A

    Six anomalies

    two transactions; time runs down
    Dirty readT1T2set x = 0read x → 0rollback✕ T2 used a value that never existed
    Non-repeatable readT1T2read x → 100set x = 50commitread x → 50✕ One row, two answers in one transaction
    PhantomT1T2count rows → 2insert a rowcommitcount rows → 3✕ The same search finds a new row
    Lost updateT1T2read x → 100read x → 100set x = 110commitset x = 120commit✕ x = 120: the deposit of 10 is gone
    Read skewT1T2read x → 50set x = 25, y = 75commitread y → 75✕ T1 sees x + y = 125, not 100
    Write skewT1T2on call → 2on call → 2set alice offset bob offcommitcommit✕ Nobody is on call
    • The first three are about reads. The last three corrupt data that the application then writes.
    • Lost update and write skew happen at the default level, READ COMMITTED.

    An anomaly is a result that no one-at-a-time order of the same transactions could give.

    B

    What each level prevents in Postgres

    every cell is a recorded run
    anomalyREAD UNCOMMITTEDREAD COMMITTEDREPEATABLE READSERIALIZABLE
    Dirty readPreventedPreventedPreventedPrevented
    Non-repeatable readOccursOccursPreventedPrevented
    PhantomOccursOccursPreventedPrevented
    Lost updateOccursOccurs40001, retry40001, retry
    Read skewOccursOccursPreventedPrevented
    Write skewOccursOccursOccurs40001, retry
    • READ UNCOMMITTED behaves as READ COMMITTED. The standard allows dirty reads there; Postgres never shows them.
    • The standard allows phantoms at REPEATABLE READ. Postgres prevents them, because the whole transaction reads one snapshot.
    • REPEATABLE READ and SERIALIZABLE stop a lost update with error 40001. The application must retry.
    • Only SERIALIZABLE stops write skew. It uses Serializable Snapshot Isolation, which aborts a transaction instead of blocking it.

    In Postgres, REPEATABLE READ is snapshot isolation. It stops phantoms and lost updates, but it lets write skew through.

    C

    When the snapshot is taken

    per statement or per transaction
    READ COMMITTED: a new snapshot for each statementT1T2 commits x = 50read x → 100read x → 50REPEATABLE READ, SERIALIZABLE: one snapshot for the whole transactionT1T2 commits x = 50read x → 100read x → 100= a snapshot is taken
    • A snapshot is the set of transactions that had committed when it was taken.
    • Readers never block writers, and writers never block readers. Each reader sees its snapshot.
    • Two writers to one row still queue on the row lock, at every level.

    READ COMMITTED takes a new snapshot for each statement. REPEATABLE READ keeps the first one.

    D

    Try it: two sessions, one interleaving

    recorded from Postgres; pick an anomaly and a level
    anomaly
    level

    Both deposits commit, but the balance shows only one of them.

    tSession 1Session 2
    1BEGIN ISOLATION LEVEL READ COMMITTEDBEGIN
    2BEGIN ISOLATION LEVEL READ COMMITTEDBEGIN
    3SELECT balance FROM accounts WHERE id = 1balance = 100
    4SELECT balance FROM accounts WHERE id = 1balance = 100
    5UPDATE accounts SET balance = 110 WHERE id = 1UPDATE 1
    6UPDATE accounts SET balance = 120 WHERE id = 1waits for session 1lock: transactionid ShareLock
    7COMMITCOMMITstill waiting
    8resumesUPDATE 1
    9COMMITCOMMIT
    statement 9 of 9
    Anomaly occurs. After both sessions: balance = 120

    The lab sends each statement from two real connections, in this order. A shaded lane is waiting on a lock, read from pg_locks.

    At READ COMMITTED the second UPDATE waits, then overwrites. At REPEATABLE READ the same UPDATE waits, then fails with 40001.

    E

    Capabilities used

    what Postgres gives you
    toolcapabilitywhat it gives you herealso used for
    PostgresRow versions (MVCC)Readers see a snapshot. They never wait for writers, and writers never wait for them.Long reports on a busy table
    PostgresA snapshot per statement (READ COMMITTED)Each statement sees all data committed before it started.The default for most applications
    PostgresA snapshot per transaction (REPEATABLE READ)Every read in the transaction sees one point in time. No read skew, no phantoms.Consistent reports, backups
    PostgresRe-check after a wait (READ COMMITTED)An UPDATE that waited reads the newest row again and tests its WHERE again.Atomic counters, conditional updates
    PostgresFirst writer wins (REPEATABLE READ and up)A write to a row that changed after the snapshot fails with 40001.Optimistic concurrency without a version column
    PostgresRow locks: SELECT ... FOR UPDATEThe rows a decision reads cannot change until it commits.Transfers, stock, seat maps
    PostgresPredicate locks (SIREAD) under SERIALIZABLERecord what each transaction read. A read/write cycle aborts one transaction.Invariants across rows, with no lock design
    PostgresUNIQUE, CHECK and EXCLUDE constraintsThe database refuses the bad state, whatever the code does.Bookings, balances that stay positive
    PostgresSIREAD locks can cover a page or a tableLimit Transactions that touch nearby rows can also abort. Retry them.
    ServiceA retry loopRuns the whole transaction again after 40001 or 40P01.Deadlocks, failover

    Postgres gives me snapshots, row locks, constraints, and serializable checks that abort instead of block. I pick the cheapest one that protects the invariant.

    F

    Fixes, and when to use each

    tested at the level shown
    fixstopswherestatus
    Atomic UPDATE: balance = balance + 10Lost updatePostgresOne row
    Conditional UPDATE on a version, count rowsLost updatePostgresApproved
    SELECT ... FOR UPDATE on the rows the decision readsLost update, write skewPostgresRows known
    UNIQUE, CHECK or EXCLUDE constraintWrite skew on insertsPostgresRule fits
    SERIALIZABLE, retry on 40001All sixPostgres RetryLow contention
    REPEATABLE READ aloneNot write skewPostgresNot approved
    Read, decide in the app, write laterNothingServiceNot approved
    • Rows known: the decision reads rows you can name and lock.
    • Rule fits: the invariant is "no two rows with the same key", a row check, or "no overlap".
    • Low contention: few transactions touch the same rows, so few retries.

    One row: an atomic or conditional update. A read-then-write decision: FOR UPDATE. A rule that fits a constraint: the constraint. The rest: SERIALIZABLE with retry.

    G

    Try it: each fix

    recorded from Postgres

    The locking read makes session 2 wait. After the wait it reads 110, not 100.

    tSession 1Session 2
    1BEGIN ISOLATION LEVEL READ COMMITTEDBEGIN
    2BEGIN ISOLATION LEVEL READ COMMITTEDBEGIN
    3SELECT balance FROM accounts WHERE id = 1 FOR UPDATEbalance = 100
    4SELECT balance FROM accounts WHERE id = 1 FOR UPDATEwaits for session 1lock: transactionid ShareLock
    5UPDATE accounts SET balance = 110 WHERE id = 1UPDATE 1still waiting
    6COMMITCOMMITstill waiting
    7resumesbalance = 110
    8UPDATE accounts SET balance = 130 WHERE id = 1UPDATE 1110 + 20, from the value it read after the wait
    9COMMITCOMMIT
    statement 9 of 9
    Both deposits count. After both sessions: balance = 130

    A fix either makes the second session wait and re-read, or makes Postgres refuse the bad write.

    H

    SERIALIZABLE with a retry

    pseudo code
    run with retrypseudo code
    run(transaction):
      REPEAT up to N times:
        BEGIN ISOLATION LEVEL SERIALIZABLE1
          read what the decision needs2
          decide, then write
        COMMIT
        IF it committed: RETURN
        IF error is 40001 or 40P013:      // safe to run again
          wait a few random ms4
        ELSE: RETURN the error
      RETURN gave up5
    1. 1Set the level per transaction. The rest of the application stays at READ COMMITTED.
    2. 2The reads go inside the loop. A retry must read the new state.
    3. 3serialization_failure and deadlock_detected. Postgres says the transaction might succeed if retried.
    4. 4A random wait stops the same two transactions from colliding again at once.
    5. 5Cap the attempts. Many retries on the same rows mean the design needs a lock instead.
    Tested source Go: the retry loop
    Go: the retry loopgo
    
    // Serializable runs fn in a SERIALIZABLE transaction. When Postgres aborts it to keep the
    // result serializable, it runs fn again from the start. It returns the number of retries.
    func Serializable(ctx context.Context, db *pgxpool.Pool, maxAttempts int, fn func(pgx.Tx) error) (int, error) {
      for attempt := 1; ; attempt++ {
        err := pgx.BeginTxFunc(ctx, db, pgx.TxOptions{IsoLevel: pgx.Serializable}, fn)
        if err == nil {
          return attempt - 1, nil
        }
        if !Retryable(err) || attempt == maxAttempts {
          return attempt - 1, fmt.Errorf("serializable transaction, attempt %d: %w", attempt, err)
        }
        // Wait a random time that grows with each attempt, up to 10 ms, so the same pair of
        // transactions does not collide again at once.
        time.Sleep(time.Duration(rand.Int64N(int64(min(attempt, 10)) * int64(time.Millisecond))))
      }
    }
    

    Under SERIALIZABLE I wrap the whole transaction in a retry loop. A 40001 is normal there, not a bug.

    I

    How SERIALIZABLE sees write skew

    a read/write cycle
    T1sets alice offT2sets bob offT1 read bob's row, which T2 then wroteT2 read alice's row, which T1 then wroteA cycle: no serial order fits. One COMMIT gets 40001.
    • SIREAD locks never block. They only record what each transaction read.
    • In the recorded run, both UPDATEs succeed. The second COMMIT fails: "Canceled on identification as a pivot, during commit attempt."
    • The retry reads 1 doctor on call, so bob stays.

    SERIALIZABLE tracks reads with SIREAD locks. Two transactions that each read what the other wrote form a cycle, so one commit fails.

    J

    Failure cases

    what breaks, and what stops it
    eventresultwhat stops itsaved by
    A SERIALIZABLE transaction gets 40001 and the code does not retryThe user sees an error for a valid request.Wrap every SERIALIZABLE transaction in the retry loop.Retry loop
    Many transactions update one hot row under SERIALIZABLEMost attempts abort: about 70% on a hot pair in our lab.Lock the hot rows with FOR UPDATE. Transactions queue and commit once each.Row lock
    A retried transaction already sent an emailThe email goes out twice.Do side effects after the commit, or write them to an outbox table in the same transaction.After commit
    A report sums balances at READ COMMITTED during transfersThe total is wrong: read skew.Run the report at REPEATABLE READ. It reads one snapshot and takes no locks.Snapshot
    Two requests check a slot, then both insertA double booking.A UNIQUE constraint. The second insert fails with 23505.Constraint
    Two transfers lock the same two rows in opposite orderA deadlock. Postgres cancels one with 40P01.Lock rows in a fixed order, for example by id. Retry 40P01 like 40001.Lock order
    A REPEATABLE READ report runs for an hourOld row versions stay, because its snapshot still needs them. Tables grow.Keep transactions short. Run long reports on a replica.Replica
    K

    Scale ladder

    start at READ COMMITTED; climb on a signal
    Each step adds one tool1READ COMMITTED2+ FOR UPDATE3+ SERIALIZABLE4+ guard row5+ one writermore load →
    Transfers per second, 16 clients1001k10k100kSERIALIZABLE, 2 rows: 1,571 transfers per secondSERIALIZABLE, 2 rows1,571FOR UPDATE, 2 rows: 1,970 transfers per secondFOR UPDATE, 2 rows1,970SERIALIZABLE, 16 rows: 3,059 transfers per secondSERIALIZABLE, 16 rows3,059FOR UPDATE, 16 rows: 4,190 transfers per secondFOR UPDATE, 16 rows4,190SERIALIZABLE, 10k rows: 10,409 transfers per secondSERIALIZABLE, 10k rows10,409FOR UPDATE, 10k rows: 10,953 transfers per secondFOR UPDATE, 10k rows10,953transfers per second, log scale
    stepaddit handlesmove up when you see
    1READ COMMITTED, with atomic and conditional updates and constraints.Most writes that touch one row. About 10,000 transfers a second on spread rows in our lab.A decision reads one row and writes another: check, then act.
    2FOR UPDATE on the rows each decision reads, in a fixed order.A hot pair of rows: 1,000 to 2,000 transfers a second, with no retries.The invariant covers rows you cannot name in advance: a count, a range, "at least one".
    3SERIALIZABLE with a retry loop, for those transactions only.3% to 7% of attempts retried on 1,000 to 10,000 rows.Retries climb: about 60% on 16 hot rows, about 70% on 2.
    4A guard row per invariant, for example one row per shift. Lock it FOR UPDATE.Only transactions on the same invariant queue. Others run in parallel.One guard row takes more than about 2,500 lock-and-update transactions a second.
    5One writer per key: route all writes for a key through one queue consumer.The key's writes run one at a time with no database lock. Batch them.Top of the ladder.

    The lab transfer reads two balances and writes both. FOR UPDATE runs at READ COMMITTED and locks rows in id order. Do not start at SERIALIZABLE for everything: every caller then needs a retry loop.

    I start at READ COMMITTED with atomic updates and constraints. I add locks where a decision reads rows, and SERIALIZABLE only where an invariant spans rows and contention is low.

    L

    Drill

    predict, then reveal

    0 of 9 known

    1. You set READ UNCOMMITTED in Postgres. Can a query read data that another transaction has not committed?

    2. At READ COMMITTED, two deposits both read 100, then write 110 and 120. What is the balance after both commit?

    3. The same two deposits run at REPEATABLE READ. What happens?

    4. Does REPEATABLE READ prevent phantoms in Postgres?

    5. Why does REPEATABLE READ still allow write skew?

    6. How does SERIALIZABLE stop write skew without blocking?

    7. One account receives 2,000 transfers a second. SERIALIZABLE or FOR UPDATE?

    8. What does a retry loop run again after 40001?

    9. Two guests check that a slot is free, then both insert a booking. What stops a double booking at READ COMMITTED?

    M

    Numbers to say

    measured in the lab
    hot pair
    SERIALIZABLE retried 67% to 75% of attempts. FOR UPDATE: 1,000 to 2,000 transfers a second, no retries.
    16 rows
    SERIALIZABLE retried 57% to 65% of attempts.
    spread
    3% to 7% retried on 1,000 to 10,000 rows. About 10,000 transfers a second either way.
    levels
    4 names, 3 behaviours in Postgres. The default is READ COMMITTED.
    errors
    40001 serialization failure, 40P01 deadlock: retry both. 23505: a unique key exists.

    Postgres 16 on an 8-core laptop, 16 clients, 3-second runs, four runs. The bars show one run. Use these as orders of magnitude.