Lesson 0005 · Transactions & 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.
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:
The wraparound danger horizon is ~2 billion (not 4) because XID comparison is:
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.
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.
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."
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.
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.
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'."
| Question | MySQL / InnoDB | PostgreSQL |
|---|---|---|
| Default isolation level | Repeatable Read | Read Committed |
| Dirty reads possible? | No (MVCC) | No — Read Uncommitted = Read Committed |
| How RR stops phantoms | Next-key / gap locks (block) | Pure snapshot (no gap locks) |
| RR write conflict | Waits on a lock; may deadlock | Errors 40001 — app retries |
| Prevents write skew? | Not at RR | Yes, at Serializable (SSI) |
| Concurrency style | Pessimistic (locking) | Optimistic (detect + retry) |
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.
From memory. Two items reach back to the storage pillar on purpose.
In one sentence, an isolation level in Postgres mainly decides:
Two SELECTs in one transaction return different data. The level is:
Under Repeatable Read, an UPDATE to a row a concurrent txn already changed:
The anomaly Serializable prevents but Repeatable Read allows is:
A snapshot, as used by every level here, is essentially: (recall 0004)
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.
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.