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.
- 1Postgres has no dirty reads at any level. READ UNCOMMITTED acts as READ COMMITTED.
- 2READ COMMITTED reads a new snapshot per statement: lost updates, read skew and write skew get through.
- 3REPEATABLE READ is snapshot isolation. It stops phantoms and lost updates, but not write skew.
- 4Default to READ COMMITTED with targeted locks. Use SERIALIZABLE when an invariant spans rows and contention is low.
- 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.
| anomaly | READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE |
|---|---|---|---|---|
| Dirty read | Prevented | Prevented | Prevented | Prevented |
| Non-repeatable read | Occurs | Occurs | Prevented | Prevented |
| Phantom | Occurs | Occurs | Prevented | Prevented |
| Lost update | Occurs | Occurs | 40001, retry | 40001, retry |
| Read skew | Occurs | Occurs | Prevented | Prevented |
| Write skew | Occurs | Occurs | Occurs | 40001, 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.
- 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.
Both deposits commit, but the balance shows only one of them.
| t | Session 1 | Session 2 |
|---|---|---|
| 1 | BEGIN ISOLATION LEVEL READ COMMITTEDBEGIN | |
| 2 | BEGIN ISOLATION LEVEL READ COMMITTEDBEGIN | |
| 3 | SELECT balance FROM accounts WHERE id = 1balance = 100 | |
| 4 | SELECT balance FROM accounts WHERE id = 1balance = 100 | |
| 5 | UPDATE accounts SET balance = 110 WHERE id = 1UPDATE 1 | |
| 6 | UPDATE accounts SET balance = 120 WHERE id = 1waits for session 1lock: transactionid ShareLock | |
| 7 | COMMITCOMMIT | still waiting |
| 8 | resumesUPDATE 1 | |
| 9 | COMMITCOMMIT |
balance = 120The 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.
| tool | capability | what it gives you here | also used for |
|---|---|---|---|
| Postgres | Row versions (MVCC) | Readers see a snapshot. They never wait for writers, and writers never wait for them. | Long reports on a busy table |
| Postgres | A snapshot per statement (READ COMMITTED) | Each statement sees all data committed before it started. | The default for most applications |
| Postgres | A snapshot per transaction (REPEATABLE READ) | Every read in the transaction sees one point in time. No read skew, no phantoms. | Consistent reports, backups |
| Postgres | Re-check after a wait (READ COMMITTED) | An UPDATE that waited reads the newest row again and tests its WHERE again. | Atomic counters, conditional updates |
| Postgres | First 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 |
| Postgres | Row locks: SELECT ... FOR UPDATE | The rows a decision reads cannot change until it commits. | Transfers, stock, seat maps |
| Postgres | Predicate locks (SIREAD) under SERIALIZABLE | Record what each transaction read. A read/write cycle aborts one transaction. | Invariants across rows, with no lock design |
| Postgres | UNIQUE, CHECK and EXCLUDE constraints | The database refuses the bad state, whatever the code does. | Bookings, balances that stay positive |
| Postgres | SIREAD locks can cover a page or a table | Limit Transactions that touch nearby rows can also abort. Retry them. | |
| Service | A retry loop | Runs 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.
| fix | stops | where | status |
|---|---|---|---|
| Atomic UPDATE: balance = balance + 10 | Lost update | Postgres | One row |
| Conditional UPDATE on a version, count rows | Lost update | Postgres | Approved |
| SELECT ... FOR UPDATE on the rows the decision reads | Lost update, write skew | Postgres | Rows known |
| UNIQUE, CHECK or EXCLUDE constraint | Write skew on inserts | Postgres | Rule fits |
| SERIALIZABLE, retry on 40001 | All six | Postgres Retry | Low contention |
| REPEATABLE READ alone | Not write skew | Postgres | Not approved |
| Read, decide in the app, write later | Nothing | Service | Not 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.
The locking read makes session 2 wait. After the wait it reads 110, not 100.
| t | Session 1 | Session 2 |
|---|---|---|
| 1 | BEGIN ISOLATION LEVEL READ COMMITTEDBEGIN | |
| 2 | BEGIN ISOLATION LEVEL READ COMMITTEDBEGIN | |
| 3 | SELECT balance FROM accounts WHERE id = 1 FOR UPDATEbalance = 100 | |
| 4 | SELECT balance FROM accounts WHERE id = 1 FOR UPDATEwaits for session 1lock: transactionid ShareLock | |
| 5 | UPDATE accounts SET balance = 110 WHERE id = 1UPDATE 1 | still waiting |
| 6 | COMMITCOMMIT | still waiting |
| 7 | resumesbalance = 110 | |
| 8 | UPDATE accounts SET balance = 130 WHERE id = 1UPDATE 1110 + 20, from the value it read after the wait | |
| 9 | COMMITCOMMIT |
balance = 130A fix either makes the second session wait and re-read, or makes Postgres refuse the bad write.
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- 1Set the level per transaction. The rest of the application stays at READ COMMITTED.
- 2The reads go inside the loop. A retry must read the new state.
- 3serialization_failure and deadlock_detected. Postgres says the transaction might succeed if retried.
- 4A random wait stops the same two transactions from colliding again at once.
- 5Cap the attempts. Many retries on the same rows mean the design needs a lock instead.
Tested source Go: the retry loop
// 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.
- 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.
| event | result | what stops it | saved by |
|---|---|---|---|
| A SERIALIZABLE transaction gets 40001 and the code does not retry | The 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 SERIALIZABLE | Most 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 email | The 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 transfers | The 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 insert | A double booking. | A UNIQUE constraint. The second insert fails with 23505. | Constraint |
| Two transfers lock the same two rows in opposite order | A 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 hour | Old row versions stay, because its snapshot still needs them. Tables grow. | Keep transactions short. Run long reports on a replica. | Replica |
| step | add | it handles | move up when you see |
|---|---|---|---|
| 1 | READ 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. |
| 2 | FOR 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". |
| 3 | SERIALIZABLE 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. |
| 4 | A 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. |
| 5 | One 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.
0 of 9 known
You set READ UNCOMMITTED in Postgres. Can a query read data that another transaction has not committed?
At READ COMMITTED, two deposits both read 100, then write 110 and 120. What is the balance after both commit?
The same two deposits run at REPEATABLE READ. What happens?
Does REPEATABLE READ prevent phantoms in Postgres?
Why does REPEATABLE READ still allow write skew?
How does SERIALIZABLE stop write skew without blocking?
One account receives 2,000 transfers a second. SERIALIZABLE or FOR UPDATE?
What does a retry loop run again after 40001?
Two guests check that a slot is free, then both insert a booking. What stops a double booking at READ COMMITTED?
- 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.