Lesson 0005 · Transactions & Isolation

Snapshot isolation

A snapshot is just a captured set of transaction IDs — so an isolation level isn't a new mechanism, only a choice of when you take that snapshot. Three levels, one idea.

~11 minWarm-up recall + quiz + MySQL contrastCheat sheet: here

Warm-up: recall the storage pillar first

Closed book. Both threads feed straight into today's one idea.

MVCC decides if a tuple is visible to you by comparing its header XIDs against:

Visibility is a comparison of the tuple's xmin/xmax against your snapshot. Today's lesson is all about what that "snapshot" is and when it's taken.

The wraparound danger horizon is ~2 billion (not 4) because XID comparison is:

Modulo-2³² arithmetic makes the XID space a circle, so from any point only ~2 billion values are "past." Beyond that midpoint an old row flips into the "future" and disappears.

Last lesson you learned MVCC decides visibility by comparing a tuple's xmin/ xmax against your snapshot. Now name the snapshot precisely: it is a captured list of which transactions had committed at a chosen instant — effectively a set of XIDs. Given that, the whole topic of isolation collapses to a single question: when do you take the snapshot, and how long do you hold it? Everything else is bookkeeping.

Postgres has three levels — and never dirty-reads

You can ask for any of the four SQL-standard levels, but Postgres implements three: Read Committed (the default), Repeatable Read, and Serializable. Asking for Read Uncommitted quietly gives you Read Committed. PostgreSQL Docs: "internally only three distinct isolation levels are implemented, i.e., PostgreSQL's Read Uncommitted mode behaves like Read Committed." and "Read Committed is the default isolation level in PostgreSQL." Because every read is served from an MVCC snapshot of committed data, dirty reads are impossible in Postgres at any level — a floor the storage model gives you for free.

Read Committed — a fresh snapshot per statement

At the default level, each statement takes its own new snapshot at the moment it begins. PostgreSQL Docs: "a SELECT query… sees a snapshot of the database as of the instant the query begins to run." The consequence you must hold onto: two statements in the same transaction can see different data, if someone else commits in between. PostgreSQL Docs: "two successive SELECT commands can see different data, even though they are within a single transaction, if other transactions commit changes after the first SELECT starts and before the second SELECT starts."

Repeatable Read — one snapshot for the whole transaction

Move the snapshot earlier and hold it: Repeatable Read takes a single snapshot at the start of the transaction's first real statement and every statement uses that same one. PostgreSQL Docs: "a query in a repeatable read transaction sees a snapshot as of the start of the first non-transaction-control statement in the transaction… successive SELECT commands within a single transaction see the same data." Same machinery as Read Committed — the only change is when the snapshot is taken. That one move buys repeatable reads and no phantoms: Postgres's Repeatable Read is full snapshot isolation.

The behavioral fork migrators must know

Reads are frozen — but what about writes to a row someone else changed after your snapshot? Read Committed re-reads the latest committed version and proceeds. Repeatable Read can't — its snapshot is fixed — so it gives up and errors:

ERROR: could not serialize access due to concurrent update

PostgreSQL Docs — repeatable read: "a repeatable read transaction cannot modify or lock rows changed by other transactions after the repeatable read transaction began… it should abort the current transaction and retry the whole transaction from the beginning." This is optimistic concurrency: don't block, detect the conflict, make the app retry. Contrast MySQL's Repeatable Read, which pessimistically takes next-key (gap) locks to hold the line and make the other writer wait. Same level name, opposite strategy — your code must be ready to catch and retry.

Serializable — snapshot isolation that also catches write skew

Snapshot isolation has one famous hole: write skew. Two transactions each read an overlapping set, each checks a rule that currently holds, and each writes based on what it read — individually fine, jointly impossible in any serial order. Repeatable Read permits it; both commit. PostgreSQL Docs: "If either transaction were running at the Repeatable Read isolation level, both would be allowed to commit; but since there is no serial order of execution consistent with the result, using Serializable transactions will allow one transaction to commit and will roll the other back."

Serializable is Repeatable Read plus a watchdog. It runs the same snapshot machinery but also monitors read/write dependencies between live transactions (via predicate locking) and aborts one if the interleaving couldn't have happened serially. PostgreSQL Docs: "This level emulates serial transaction execution… as if transactions had been executed one after another"; implemented via "Serializable Snapshot Isolation, which builds on Snapshot Isolation by adding checks for serialization anomalies." The abort you'll see:

ERROR: could not serialize access due to read/write dependencies among transactions

Same deal as RR — you must retry. In fact any serialization failure returns SQLSTATE 40001, so a single retry loop handles both RR and Serializable. PostgreSQL Docs: "Applications using this level must be prepared to retry transactions due to serialization failures… which always return with an SQLSTATE value of '40001'."

ONE IDEA, THREE LEVELS — it's all about the snapshot Read Committed │ new snapshot at the start of EACH statement (default) │ → statements in one txn can disagree; writes re-read │ Repeatable Read │ ONE snapshot at the txn's first statement, held to commit (snapshot iso) │ → stable reads, no phantoms; write conflict ⇒ 40001 retry │ Serializable │ Repeatable Read + read/write-dependency watchdog (SSI) │ → also kills write skew; anomaly ⇒ 40001 retry

Postgres vs MySQL — same words, different machines

QuestionMySQL / InnoDBPostgreSQL
Default isolation levelRepeatable ReadRead Committed
Dirty reads possible?No (MVCC)No — Read Uncommitted = Read Committed
How RR stops phantomsNext-key / gap locks (block)Pure snapshot (no gap locks)
RR write conflictWaits on a lock; may deadlockErrors 40001 — app retries
Prevents write skew?Not at RRYes, at Serializable (SSI)
Concurrency stylePessimistic (locking)Optimistic (detect + retry)
The migration trap

Move an app from MySQL to Postgres and your default isolation silently changes from Repeatable Read to Read Committed — long-held read consistency you took for granted is gone unless you ask for it. And if you do set Repeatable Read or Serializable, code that never needed a retry loop under InnoDB's blocking model will now hit 40001 under load. Neither is a bug; both are the optimistic model showing through.

Check yourself

From memory. Two items reach back to the storage pillar on purpose.

In one sentence, an isolation level in Postgres mainly decides:

All three levels use the same snapshot machinery; they differ in when the snapshot is captured and how long it's held. Postgres leans on MVCC, not read locks.

Two SELECTs in one transaction return different data. The level is:

Read Committed takes a fresh snapshot per statement, so a concurrent commit between the two SELECTs is visible to the second. RR and Serializable hold one snapshot for the whole transaction.

Under Repeatable Read, an UPDATE to a row a concurrent txn already changed:

Its snapshot is fixed, so it can't touch a row changed after it began — it raises "could not serialize access due to concurrent update" (40001) and the app must retry. Gap locks are MySQL's approach.

The anomaly Serializable prevents but Repeatable Read allows is:

RR (snapshot isolation) already blocks dirty and phantom reads, but permits write skew. Serializable's SSI watchdog detects the dangerous read/write dependency and aborts one transaction.

A snapshot, as used by every level here, is essentially: (recall 0004)

A snapshot records which transactions had committed at an instant — a set of XIDs. Visibility compares each tuple's xmin/xmax against it. No data is copied and no read locks are taken.
Optional — when you have an instance

Two psql sessions side by side make the snapshot fork visible. Run left-to-right:

-- session A                        -- session B
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT val FROM t WHERE id = 1;        -- snapshot taken here; say val = 10
                                       UPDATE t SET val = 20 WHERE id = 1;  -- commits
SELECT val FROM t WHERE id = 1;        -- STILL 10 — snapshot is held
UPDATE t SET val = val + 1 WHERE id=1; -- ERROR: could not serialize access…

Now redo it with BEGIN alone (Read Committed): the second SELECT shows 20, and the UPDATE succeeds against the new version. Same script, different snapshot timing — that's the entire lesson. Bring the error to your teacher and we'll trace it.

Primary source — read this next

PostgreSQL Docs — 13.2 Transaction Isolation (each level with its exact guarantees, the concurrent-update error, and the worked write-skew example for Serializable). For the snapshot/XID mechanics underneath, revisit lesson 0004 and Suzuki's "Internals of PostgreSQL," ch. 5.