Isolation Levels: The Table That Does Not Describe Your Database
Why the ANSI isolation table describes symptoms rather than mechanisms, what MVCC changed, and how snapshot isolation still lets two correct transactions corrupt each other.

Almost every database conversation about isolation starts from a four-row table. That table is the ANSI SQL definition, and it is genuinely useful, and mapping it onto a real engine reliably produces a discussion where both people are correct and nobody learns anything.
The problem is that the table is organised around symptoms. It says which anomalies are prevented and says nothing about what the engine does, so you cannot reason from the table to a plan.
Once you reason from the mechanism instead, most anomalies stop being a settings question. A predicate lock is a lock, and locks are the subject of deadlocks and lock ordering. A stale read is a visibility question, and visibility is what the primary and the replica disagree about before the replica catches up: replica lag and read-your-writes.
The table, and why it misleads
dirty read non-repeatable read phantom read
------------------------------------------------
read uncommitted possible possible possible
read committed no possible possible
repeatable read no no possible
serializable no no no
This is the SQL-standard phenomenon table. PostgreSQL’s implementation is stronger in two places: Read Uncommitted behaves as Read Committed, and PostgreSQL Repeatable Read also prevents phantom reads while still allowing serialization anomalies such as write skew.
Read as a specification this is fine. Read as a description of a running system it fails immediately, because the entries are defined as “the database must not allow this anomaly”, and there are far more anomalies than the four named ones.
The full list of anomalies runs past a dozen and includes write skew, read skew, lost update, and the serialization anomalies from Adya’s formal treatment. Any engine that claims serializable isolation is claiming all of them are impossible, not the three in the table. The table is a subset that happened to be the interesting ones in 1992.
The second problem is that the table implies a mechanism. It invites the reading that Read Committed means shared locks, Repeatable Read means shared locks held to commit, and Serializable means exclusive locks everywhere. Engines implement these guarantees with different combinations of MVCC, locking, validation, and dependency tracking, so the table alone cannot predict blocking or abort behavior.
MVCC changed the question
The anomalies in the table mostly come from one problem: a reader can observe a write that is not yet committed. Blocking that observation is what locks are for, and a reader who blocks a writer is a throughput problem.
MVCC changes the ordinary row-read path. Instead of one version of each row, there are several, tagged with creating and deleting transactions. A plain reader uses a snapshot and normally does not block a row writer; the writer normally does not block that plain reader. SELECT ... FOR UPDATE, conflicting writes, explicit table locks, and DDL are outside that simple statement.
row 88213
xmin 501 xmax 604 committed by T501, replaced by T604 <- old version
xmin 604 xmax 0 committed by T604, current <- new version
reader with snapshot at xmin 500 sees neither, row did not exist yet
reader with snapshot at xmin 550 sees the T501 version
reader with snapshot at xmin 610 sees the T604 version
Dirty reads disappear from PostgreSQL’s offered behavior. A version written by an uncommitted transaction is not visible to another transaction’s ordinary snapshot.
So Postgres offers three isolation levels rather than four, and the mapping is not intuitive:
| Level | Behaviour in Postgres |
|---|---|
| Read Uncommitted | Not a distinct level. A read is a snapshot, and it cannot see uncommitted data, so it behaves as Read Committed. |
| Read Committed | A new snapshot per statement. Default. |
| Repeatable Read | A new snapshot per transaction. |
| Serializable | Repeatable Read plus conflict detection, aborting one transaction when a cycle is detected. |
Do not transfer this table mechanically to another engine. InnoDB uses next-key locking in relevant modes, SQL Server offers both locking and row-versioning behaviors, CockroachDB defaults to Serializable but also supports configurable Read Committed, and FoundationDB defaults to strict serializability with optimistic conflict detection. The label is the beginning of the documentation lookup, not the end.
Read Committed surprises in two ways
The default level has a behaviour that catches everyone once, and it is not the one the table predicts.
A transaction sees a different world in each statement. The snapshot is taken per statement, so this is legal and correct:
BEGIN;
SELECT total_cents FROM orders WHERE id = 88213; -- 4,900
-- another transaction commits: total is now 5,900
SELECT total_cents FROM orders WHERE id = 88213; -- 5,900, same transaction
COMMIT;
Application code that reads the same row twice inside a transaction and compares the values is relying on something Read Committed does not provide. This is a very common source of a check-then-act bug that only reproduces under concurrency.
An UPDATE re-checks its predicate after a conflict. This one is subtler and it is the mechanism behind most lost-update arguments.
-- T1 and T2 both run, concurrently, at Read Committed
T1: UPDATE orders SET status = 'paid' WHERE id = 88213 AND status = 'pending';
T2: UPDATE orders SET status = 'cancelled' WHERE id = 88213 AND status = 'pending';
T2 finds the row, sees pending, and tries to lock it. T1 holds the lock. T2 waits. T1 commits. T2 wakes up, and re-evaluates the WHERE status = 'pending' against the row as it now exists.
T1 acquires the lock, sets status = 'paid', commits
T2 was blocked, re-evaluates: status is now 'paid', not 'pending'
T2's WHERE no longer matches, so T2 updates 0 rows
That behaviour is correct and intentional, and it is what makes this a conditional update rather than a lost update. It is also invisible unless you look at the row count, because the statement reports success either way. An application that treats a successful UPDATE as a changed row is making a mistake, and the fix is to check the affected count.
Write skew is the one the table hides
Repeatable Read gives every transaction a stable snapshot for its whole life. The obvious conclusion is that transactions see a consistent world and therefore cannot interfere. That conclusion is wrong, and the failure has a name.
shifts table, one night shift, one slot free
T1: BEGIN ISOLATION LEVEL REPEATABLE READ;
T2: BEGIN ISOLATION LEVEL REPEATABLE READ;
T1: SELECT count(*) FROM shifts WHERE slot = 'night' AND assigned = false;
-> 1
T1: UPDATE shifts SET assigned = true WHERE id = 101;
T2: SELECT count(*) FROM shifts WHERE slot = 'night' AND assigned = false;
-> 1 (T1 has not committed, T2 cannot see it)
T2: UPDATE shifts SET assigned = true WHERE id = 102;
T1: COMMIT
T2: COMMIT
final state: two people assigned to a shift with one place
neither transaction violated anything it checked
Both transactions read a correct snapshot. Both took an action that was correct given that snapshot. The combination violates a constraint, and no lock prevented it, because each transaction only wrote a row the other never read.
This is write skew, and the same shape appears in every system built on Repeatable Read:
- two users confirming the last seat on a flight
- two doctors taking the last on-call slot
- two workers claiming the last unit of inventory
- two nodes believing they are the leader because both read the same stale heartbeat
The distinguishing property is always the same: each transaction reads a set of rows, and writes a different set, and the invariant spans both. If the two sets overlap, a lock or a predicate check catches it. If they do not overlap, Repeatable Read does not help at all.
Serializable adds dependency detection to ordinary locking
Postgres implements Serializable on top of Repeatable Read using Serializable Snapshot Isolation. The approach is worth understanding because it inverts the usual expectation.
PostgreSQL still uses ordinary row and table locks for writes. SSI adds non-blocking SIReadLock predicate-lock information and dependency tracking. It permits concurrent work to proceed, but it does not permit a non-serializable outcome to commit: a transaction is aborted when PostgreSQL detects a dangerous dependency structure.
T1 reads row A, writes row B -> rw-conflict on B
T2 reads row B, writes row A -> rw-conflict on A
dangerous structure: a cycle
SSI tracks read and write dependencies, and when it finds a
cycle with an ordering that cannot be serialised, it aborts one
of the participants:
ERROR: could not serialize access due to read/write
dependencies among transactions
The predicate-lock representation can be at tuple, page, or relation granularity depending on plan and available predicate-lock memory. These SIReadLock entries do not block writers. PostgreSQL uses them with write information to track read-write dependencies; aborts can occur before or during commit, and false positives are allowed to preserve correctness.
Two practical consequences follow, and they change how you write application code.
The error is an expected result that must be handled. A serialization failure is the database declining an execution it cannot safely serialize. Roll back the complete transaction. Retry when the operation is still wanted and safe to repeat; otherwise return a domain outcome. Bound attempts, pass cancellation, and keep non-transactional side effects outside the retry body.
const int MaxAttempts = 5;
for (var attempt = 1; ; attempt++)
{
try
{
await using var tx = await conn.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable);
var remaining = await ReadFreeSlotsAsync(conn, tx);
if (remaining == 0) throw new SoldOutException();
await ClaimSlotAsync(conn, tx, remaining[0]);
await tx.CommitAsync();
return;
}
catch (PostgresException ex) when (ex.SqlState == "40001" && attempt < MaxAttempts)
{
// 40001 is serialization_failure. Retrying is the designed path.
await Task.Delay(Random.Shared.Next(2, 40) * attempt);
}
}
Read Committed is a real choice, not a compromise to apologise for. A reporting query, a dashboard refresh, and a search request gain nothing from a transaction-wide snapshot, and on a busy system the higher abort rate of Serializable is pure cost. The level should be chosen per operation rather than set once for the connection.
What a phantom is, at the index level
The table lists phantoms as the hardest anomaly to prevent, and it is worth being precise about why, because the reason is an implementation detail that also explains the fix.
A phantom is a row that appears or disappears inside a range a transaction has already read. At the index level, a range scan reads a sequence of index entries pointing at heap pages.
predicate: WHERE slot = 'night' AND assigned = false
scan reads index entries: 101 -> heap page 4
102 -> heap page 9
a concurrent transaction inserts 103
-> a new index entry, and a new heap page
a re-scan now finds 103, which was not in the first result
Nothing in the first scan marked those pages, because the transaction never intended to block the insertion. A conventional B-tree index cannot know that a range scan is about to happen. It only finds out when a write inserts into a region the reader has already passed.
PostgreSQL’s Serializable mode does not block the index gap. It records the predicate read and detects read-write dependency patterns when another transaction changes the relevant range. Other MVCC engines can choose differently: InnoDB, for example, uses next-key and gap locking to prevent relevant phantoms under locking reads.
Under PostgreSQL Serializable, a transaction may observe its own snapshot while a concurrent insert proceeds, but the combination is not allowed to produce a non-serializable committed history. The trade is abort-and-retry pressure rather than blocking predicate ranges. Neither failure mode is universally better; workload contention and application retry cost decide.
A production-ready architecture
operation
|
+-- read only, no cross-row invariant
| READ COMMITTED, no transaction
| snapshot per statement, no aborts, cheapest
|
+-- read-modify-write on rows the query also reads
| REPEATABLE READ
| stable snapshot, still needs retry on 40001
|
+-- invariant spans rows a transaction reads and others it writes
SERIALIZABLE
detector aborts one side, application retries
A delivery checklist:
- Choose the level per operation, not per connection. A dashboard refresh and a booking do not need the same answer.
- Handle
40001and40P01at the complete transaction boundary. Retry only safe operations, with jitter, cancellation, and one total deadline. - Bound the retry loop. Three to five attempts with jitter is enough; beyond that the operation is contended rather than unlucky.
- Check the affected row count on every conditional
UPDATE. A successful statement that matched nothing is not a success. - Stop reading the same row twice in a transaction and expecting the same value at Read Committed.
- Identify write skew by asking whether a transaction’s read set and write set overlap another transaction’s. If they do not overlap, Repeatable Read will not save you.
- Fix write skew with a constraint where you can, because a constraint is checked under a lock and no isolation level is involved.
- Do not reach for Serializable to solve a write skew you could express as a unique constraint. It is much more expensive and much harder to test.
- Make sure a serialization failure becomes an intentional retry or domain conflict, not an unexplained
500. - Measure the abort rate after enabling Serializable. A high rate means the invariant should be a constraint, or the transactions are too long.
Failure stories worth testing
Run the two-transaction write skew under Repeatable Read and commit both
The shift example above, in a test, with the transactions genuinely concurrent. If both commit and you end up with two rows assigned to one slot, you have reproduced write skew and you can prove the isolation level is not protecting you.
Do the same test under Serializable and catch the 40001
One of the two transactions should fail with 40001. Confirm the application reruns the complete decision: the retry may now return “slot unavailable” rather than commit. The invariant—not forced success of the retried request—is the assertion.
Read the same row twice in one Read Committed transaction while another session updates it
The two reads return different values from the same transaction. Nothing is broken, and it is exactly the assumption that produces check-then-act bugs in application code.
Run a conditional UPDATE from two sessions and count affected rows on the loser
The blocked transaction re-evaluates its predicate and reports zero rows affected. Confirm the application treats that as a conflict rather than a success, because most conditional-update bugs are here.
Increase predicate-lock pressure in a controlled test
Run the serializable workload with the production query plans and concurrency while observing pg_locks for SIReadLock entries and counting 40001 by operation. Plan changes can alter tuple/page/relation predicate-lock granularity, so validate after index and query changes rather than manufacturing aborts with an unrelated statement timeout.
Common mistakes
| Mistake | What actually happens | Better decision |
|---|---|---|
| Reading the ANSI table as a description of a running engine | The mapping to mechanism is wrong, so every plan built on it is a guess | Ask what the engine does, not what the table names |
| Expecting Read Uncommitted to show uncommitted rows | A snapshot read cannot see them, so it is just Read Committed | Do not design around a level that does not exist |
| Assuming one snapshot per transaction is the default | Read Committed takes one per statement | Choose the level deliberately per operation |
| Assuming Repeatable Read prevents concurrency anomalies | Write skew slips through because read and write sets do not overlap | Serializable, or a constraint |
| Believing a successful UPDATE means a row changed | A blocked statement re-checks its predicate and matches nothing | Check the affected row count |
| Treating a 40001 as an application defect | It is an expected possible result of Serializable execution | Roll back the whole transaction and retry only when safe |
| Retrying a serialisation failure in a loop with no cap | Contention converts into a retry storm | Cap at a few attempts with backoff |
| Using Serializable to fix a write skew a unique index can prevent | Higher abort rate, more complex retries, weaker testability | Express the invariant as a constraint |
| Splitting reads across services to avoid a lock | Atomicity across the boundary is gone entirely | Design the boundary so the invariant lives on one side |
| Assuming every MVCC engine handles phantom ranges alike | PostgreSQL SSI detects dependencies; InnoDB can use next-key locks | Read the engine and access-mode semantics |
| Never measuring the abort rate | A high abort rate is invisible until it is an incident | Track 40001 by operation |
The complete story in one minute
The isolation table names phenomena and says nothing about mechanism, so mapping it onto an engine produces a specification rather than a blocking or retry plan. PostgreSQL’s ordinary MVCC reads use snapshots and normally do not block row writers, with explicit locking and DDL as important exceptions. Read Uncommitted behaves as Read Committed; Read Committed takes a snapshot per statement; Repeatable Read uses a transaction snapshot and is stronger than the SQL-standard minimum because it also prevents phantom reads.
Write skew survives PostgreSQL Repeatable Read because each transaction can write a different row after reading a shared invariant from one stable snapshot. PostgreSQL Serializable adds SIReadLock predicate information and read-write dependency tracking; it allows concurrency to proceed but aborts a participant before a non-serializable history commits. The application must treat 40001 as an expected possible outcome and rerun the whole decision only when doing so remains safe.
Practically that makes the level a per-operation decision. A one-statement dashboard read often needs no explicit multi-statement transaction beyond the statement’s own transaction. A booking that reads availability and writes a reservation wants either a constraint that makes the invariant unbreakable or Serializable with a retry loop. And a conditional update has to check its affected row count, because a blocked statement can re-evaluate its predicate against the row another transaction changed and legitimately affect zero rows without throwing.


