Inside serializable · Aleksei Zhynguel
Interactive noteTransaction isolation · part 7 of 7Sep 2026 · 18 minPostgres 16

Inside serializable

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.

{{ outShort }}
{{ aName }} t {{ bName }}
{{ e.seq }}
{{ e.t }}{{ e.s }}
Database · committed
{{ r.name }}{{ r.val }}
{{ v.t }}{{ v.s }}
{{ pnote }}
{{ outLabel }} {{ outVal }}
{{ warmFrom }} {{ warmQ }}

{{ warmFb }}

01 · Two marbles

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.

02 · One after the other

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.

03 · At the same time
Predict first · the diagram shows ? {{ aq2 }}
The explanation opens when you answer.

{{ 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.

04 · SERIALIZABLE says no
Predict first · the diagram shows ? {{ aq3 }}
The explanation opens when you answer.

{{ 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.

05 · The contract

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.

06 · Nobody waits

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.

Deeper

You can stop here and use SERIALIZABLE correctly. The next six steps are what Postgres tracks, how it decides, and what it costs.

07 · Notes on what you read

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.

08 · Arrows

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.

09 · Looking for danger, not cycles

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.

10 · When the error arrives

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.

11 · What it costs

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.

12 · The retry loop

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.

Connections

Every idea in the series, and every step where it appears. To review, pick a term, jump to a step, and come back.

{{ kCount }}
{{ ocTerm }}

{{ ocDef }}

Related
  1. {{ r.where }}{{ r.title }}
The whole series in one table
AnomalyRead committedRepeatable readSerializableWhat stops it
Dirty readpreventedpreventedpreventedUncommitted versions are invisible · part 2, step 8
Read skewallowedpreventedpreventedOne snapshot per transaction · part 3, step 7
Lost update (read, then write)allowedone fails · 40001one fails · 40001Refusing to overwrite a newer row · part 4, step 7
Write skewallowedallowedone fails · 40001Tracking what was read (SSI) · part 5, step 6
Phantom readallowedpreventedpreventedOne snapshot per transaction · part 6, step 5
Write skew on new rowsallowedallowedone fails · 40001Predicate locks · part 6, step 9
Postgres 16 behavior, as described in chapter 13.2 of its documentation. The SQL standard allows more than this at each level.
Check yourself{{ score }}
{{ q.n }}

{{ q.q }}

{{ q.fb }}

{{ q.model }}

Three things to keep
SOME ORDERSERIALIZABLE promises a result some serial order could produce. Not which one.
NO WAITINGSIRead locks take notes and never block. The cost is aborts, not queues.
40001Retry the whole transaction. Every SERIALIZABLE codebase needs this loop.
← Phantom rows Found a mistake? Tell me and I'll fix it. Next: Why indexes are fast →