A bag holds two marbles, one black and one white. Transaction A paints every black marble white. Transaction B paints every white marble black. Each is a single UPDATE.
The only level that promises no anomalies at all. Postgres keeps that promise without making anyone wait longer, by watching what each transaction reads and occasionally saying no.
Scroll to advance. Steps 1–6 are the idea, steps 7–12 are how Postgres does it. You can switch the isolation level at any step.
{{ warmFb }}
A bag holds two marbles, one black and one white. Transaction A paints every black marble white. Transaction B paints every white marble black. Each is a single UPDATE.
Run them one after the other. If A goes first, both marbles become white, and then B paints them both black. If B goes first you get the opposite.
Either way, both marbles end up the same color.
{{ fb2 }}
Now run them together under REPEATABLE READ. A’s snapshot shows one black marble, m1, so it paints m1. B’s snapshot shows one white marble, m2, so it paints m2. Different rows, no conflict, both commit.
The colors have simply swapped. No one-after-the-other order could produce that.
{{ fb3 }}
Under SERIALIZABLE, A commits and B fails with could not serialize access due to read/write dependencies among transactions. B retries, sees two white marbles, and paints both black.
The final state is one that A-then-B would have produced.
SERIALIZABLE guarantees that the result matches some serial order. It doesn’t say which one, and it doesn’t say every transaction will succeed.
The deal is this: you write each transaction as if it ran alone, and in return you handle SQLSTATE 40001 by running the transaction again.
Postgres does this without adding any locks that block. Readers still don’t wait for writers. Transactions run exactly as they would under REPEATABLE READ, and the database keeps notes on the side.
When the notes show that no serial order is possible, it aborts one transaction. The technique is called Serializable Snapshot Isolation, or SSI.
You can stop here and use SERIALIZABLE correctly. The next six steps are what Postgres tracks, how it decides, and what it costs.
The notes are SIRead locks. Each time a SERIALIZABLE transaction reads, Postgres records what it read: a row, an index page, or a whole table. They never block anyone.
They’re kept even after the transaction commits, for as long as any transaction that overlapped with it is still running.
When one transaction writes something that a concurrent transaction read without seeing that write, Postgres draws an arrow: the reader must come before the writer.
B looked for white marbles and didn’t see A turn m1 white, so B → A. A looked for black marbles and didn’t see B turn m2 black, so A → B. Two arrows pointing at each other.
Finding every cycle in a busy database would be too slow. Postgres looks for a simpler shape: a transaction with an arrow coming in and an arrow going out, where the transaction at the far end committed first. Every real anomaly contains that shape.
Not every such shape is a real anomaly, so SSI sometimes aborts a transaction that would have been fine. Those false positives are the price of not waiting.
The error can come at a read, at a write, or at COMMIT, and Postgres tries to pick a transaction that will succeed if you retry it straight away. The diagram here only checks at commit.
Read-only transactions can also be part of an anomaly. SERIALIZABLE READ ONLY DEFERRABLE waits until it can take a snapshot that’s guaranteed safe, then runs without ever being aborted. It’s the right mode for long reports.
Memory: predicate locks live in shared memory, sized by max_pred_locks_per_transaction. When one transaction holds many row locks on a page, Postgres merges them into a page lock, and then a table lock. Coarser locks mean more false conflicts.
Retries: under contention, a real share of transactions fail and run again. And mixing: SSI only tracks SERIALIZABLE transactions. One session writing under READ COMMITTED isn’t watched, and it can break the guarantee for everyone else.
Every SERIALIZABLE codebase needs the same small loop. Run the whole transaction; if it fails with 40001, run it again from BEGIN, a few times at most, with a short random pause between attempts.
The whole transaction, not just the last statement, because everything it read may have changed. And anything outside the database, like sending an email, belongs after the commit.
40P01, deadlock detected, is worth retrying the same way.
Every idea in the series, and every step where it appears. To review, pick a term, jump to a step, and come back.
| Anomaly | Read committed | Repeatable read | Serializable | What stops it |
|---|---|---|---|---|
| Dirty read | prevented | prevented | prevented | Uncommitted versions are invisible · part 2, step 8 |
| Read skew | allowed | prevented | prevented | One snapshot per transaction · part 3, step 7 |
| Lost update (read, then write) | allowed | one fails · 40001 | one fails · 40001 | Refusing to overwrite a newer row · part 4, step 7 |
| Write skew | allowed | allowed | one fails · 40001 | Tracking what was read (SSI) · part 5, step 6 |
| Phantom read | allowed | prevented | prevented | One snapshot per transaction · part 6, step 5 |
| Write skew on new rows | allowed | allowed | one fails · 40001 | Predicate locks · part 6, step 9 |
{{ q.q }}
{{ q.fb }}
{{ q.model }}