← databases book ⊞ All topics

Isolation Levels & Read Anomalies

An isolation level is the dial that trades correctness-under-concurrency for speed. Raising it eliminates more anomalies — the ways concurrent transactions corrupt each other's view of the data — but at higher cost: more locks, more aborts-and-retries, less throughput. The four ANSI levels are defined entirely by which anomalies they forbid, so the whole topic collapses into a single table. This is the I in ACID, made tunable.

Key Components

Isolation level
A per-transaction setting that determines which concurrency anomalies are forbidden. Each level is defined not by how it works but by which anomalies it guarantees cannot happen — making the four standard levels a strict staircase from weakest to strongest.
Dirty read
Reading data written by a concurrent transaction that has not yet committed. If that writer later rolls back, the reader saw a value that never truly existed.
Non-repeatable read
Re-reading the same row inside one transaction and getting a different value, because another transaction committed an UPDATE/DELETE to that row in between. The data is real, but one question got two answers.
Phantom read
Re-running the same range query and getting a different set of rows, because another committed transaction INSERTed or DELETEd rows matching the predicate. The WHERE-clause population changed, not an individual row you already held.
Snapshot Isolation (MVCC snapshot)
An implementation where each transaction reads from a consistent point-in-time snapshot of committed data. When that snapshot is taken — fresh per statement vs. frozen for the whole transaction — is exactly what distinguishes Read Committed from Repeatable Read.
Serialization anomaly (write skew)
An outcome that no serial (one-at-a-time) ordering of the transactions could ever produce. Write skew is the classic case: two transactions read an overlapping set, each makes a locally-valid decision, and the combination breaks an invariant — even though they write different rows.
SSI / predicate locks
Serializable Snapshot Isolation: Snapshot Isolation plus runtime detection of serialization anomalies. It uses predicate locks (non-blocking) to track what each transaction read, then aborts a transaction at commit (SQLSTATE 40001) if the interleaving could not have been serial.

Concrete Example

All three classic read anomalies share one shape: a reader sees something wrong because a concurrent writer is in flight. The difference is what goes wrong.

Dirty read — reading uncommitted data. The reader sees a value the writer might still throw away:

T1: UPDATE accounts SET balance = 0 WHERE id = 1;   -- not committed
T2: SELECT balance FROM accounts WHERE id = 1;       -- reads 0  ← DIRTY
T1: ROLLBACK;                                        -- that 0 never existed

Non-repeatable read — the same row changes value if re-read. Here the other transaction committed, so the data is genuine; the problem is that one transaction got two answers to one question:

T1: SELECT balance FROM accounts WHERE id = 1;       -- reads 100
T2: UPDATE accounts SET balance = 50 WHERE id = 1; COMMIT;
T1: SELECT balance FROM accounts WHERE id = 1;       -- reads 50  ← NON-REPEATABLE

Phantom read — the same range query returns a different set of rows, because a concurrent transaction INSERTed (or DELETEd) rows matching the predicate:

T1: SELECT count(*) FROM accounts WHERE balance > 100;   -- returns 3
T2: INSERT INTO accounts (balance) VALUES (500); COMMIT;
T1: SELECT count(*) FROM accounts WHERE balance > 100;   -- returns 4  ← PHANTOM

The precise distinction matters because the two require different defenses. A non-repeatable read is an existing row you already read changing (UPDATE/DELETE of a specific row); locking rows you've read is cheap row-level locking. A phantom is the set membership changing (INSERT/DELETE); preventing it needs predicate / range / gap locking — you must lock a condition, because you cannot lock a row that does not exist yet.

The fourth, subtlest anomaly is the serialization anomaly, of which write skew is the textbook example. Two transactions read an overlapping set, each acts safely on its own snapshot, both commit, and the combination breaks an invariant neither broke alone:

-- Invariant: COUNT(on_call) >= 1.   Alice & Bob both on call.
T1 (Alice): SELECT count(*) FROM doctors WHERE on_call;  -- sees 2, "safe to leave"
T2 (Bob):   SELECT count(*) FROM doctors WHERE on_call;  -- sees 2, "safe to leave"
T1: UPDATE doctors SET on_call = false WHERE name = 'Alice'; COMMIT;
T2: UPDATE doctors SET on_call = false WHERE name = 'Bob';   COMMIT;
-- Zero doctors on call. Each transaction was individually fine.

Crucially the two transactions write different rows, so simple write-write conflict detection misses it entirely. No dirty, non-repeatable, or phantom read occurred — yet the result corresponds to no serial ordering.

Visual Model

Picture a photographer handling a moving crowd. Read Uncommitted watches the live scene — people half-out-of-frame, some who will walk away (rolled back). Read Committed takes a new photo per shot: each is of settled people, but two photos seconds apart differ. Repeatable Read takes one photo at the start and works from that print — people never move or multiply, but the real room changed, so filing a change from a stale print may be rejected. Serializable uses that single photo plus a referee who checks, after everyone files, whether all the edits could have happened in some sequential order; if two people assumed incompatible worlds, one filing is torn up.

The whole topic is one table: anomalies across the top, levels down the side, and each level forbids one more column than the last. The heatmap below shows it twice over — green where an anomaly is prevented, red where it is allowed — with annotations where Postgres is stronger than the ANSI standard requires (the standard is a floor, not an exact spec).

Level Dirty read Non-repeatable read Phantom read Serialization anomaly
Read Uncommitted allowed (but not in PG — acts like Read Committed) allowed allowed allowed
Read Committed prevented allowed allowed allowed
Repeatable Read prevented prevented allowed by ANSI, but prevented in PG allowed
Serializable prevented prevented prevented prevented

Two reads of the same table: the ANSI standard stops at the first three columns (the staircase), while Postgres adds the fourth — the serialization anomaly — and exceeds the spec by also killing phantoms at Repeatable Read. Postgres also has no real Read Uncommitted: there is no machinery that could ever show uncommitted data, so that row collapses into Read Committed.

Underneath the table, the mechanical difference between the two most-used levels is purely when the MVCC snapshot is taken. Step through two SELECTs issued inside one transaction, once under Read Committed and once under Repeatable Read, and watch a concurrent commit slip through one but not the other.

Step 1 of N
Read Committed — fresh snapshot per statement SELECT #1 snapshot A → 100 Concurrent T2 UPDATE→50; COMMIT SELECT #2 snapshot B → 50 ⚠ non-repeatable read Repeatable Read — one frozen snapshot SELECT #1 snapshot A → 100 Concurrent T2 UPDATE→50; COMMIT SELECT #2 snapshot A → 100 ✓ stable read — anomaly avoided

Loading…

Deeper — Edge Cases & Gotchas

How MVCC delivers each level

The level is essentially a choice of when the snapshot is taken and how aggressively conflicts are checked:

  • Read Committed — a fresh snapshot per statement: a SELECT sees only data committed before that query began. Because statement 2 has a newer snapshot than statement 1, non-repeatable and phantom reads slip through.
  • Repeatable Read — one snapshot, taken at the first non-transaction-control statement (not literally at BEGIN) and frozen for the whole transaction. Successive SELECTs see identical data → no non-repeatable read, and in Postgres no phantom either. This is textbook Snapshot Isolation.
  • Serializable — Repeatable Read's frozen snapshot plus runtime monitoring for serialization anomalies (SSI).

One consequence is worth memorizing: because dirty reads mean "reading uncommitted versions" and MVCC only ever shows committed versions, dirty reads are structurally impossible under MVCC. That is exactly why Postgres maps Read Uncommitted onto Read Committed — there is no mechanism that could expose an uncommitted row in the first place.

Write skew is a read/write conflict, not a write/write one

The most common misconception about the "doctors on call" scenario is that it is two transactions updating the same rows. It is not. Write skew writes different rows — Alice updates Alice, Bob updates Bob — which is precisely why write-write conflict detection (what Repeatable Read does) misses it. The conflict lives in the read sets: each transaction read data the other then wrote. Serializable Snapshot Isolation catches it by tracking read/write dependencies (a "dangerous structure" where T1 read what T2 wrote and vice versa, corresponding to no serial order) using predicate locks (SIReadLock in pg_locks). These locks are detection-only: they never block, so they can never cause a deadlock — but the optimistic detect-and-abort approach can waste work, running a transaction to completion only to abort it at commit.

Anti-pattern: using Repeatable Read or Serializable without a 40001 retry loop. Both levels are optimistic — they detect a conflict and abort rather than block — so an application that never retries is silently broken under concurrency.
ERROR:  could not serialize access due to read/write dependencies among transactions
        -- SQLSTATE 40001 (Serializable)

ERROR:  could not serialize access due to concurrent update
        -- SQLSTATE 40001 (Repeatable Read, on a concurrent update to a row it read)

Why it breaks: at these levels the database guarantees correctness by refusing to commit non-serializable interleavings, not by making everyone wait. The contract is that the application catches SQLSTATE 40001 and re-runs the entire transaction from the top. Without that loop, a doomed transaction simply surfaces the error to the user as a failure — the correctness guarantee is real, but the application threw it away.

Anti-pattern: assuming "Read Uncommitted gives dirty reads" in Postgres. The ANSI standard permits dirty reads at that level, but Postgres has no machinery to produce one — its Read Uncommitted behaves exactly like Read Committed. Code that relies on observing uncommitted data (for debugging, "progress" peeking, etc.) will never see it on Postgres. The standard specifies which anomalies must not occur; offering stronger guarantees is allowed, so the ANSI levels are a floor, not an exact specification.

Test Yourself

What is the precise difference between a non-repeatable read and a phantom read, and why does it make phantoms harder to prevent?

In MVCC terms, what single mechanical difference between Read Committed and Repeatable Read explains why RR prevents non-repeatable reads?

Why is a dirty read structurally impossible in Postgres, even at Read Uncommitted? Tie it to MVCC.

The "doctors on call" scenario commits with zero doctors on call, yet no dirty, non-repeatable, or phantom read occurred. What anomaly is this, which level prevents it, and by what mechanism?

What must any application using Repeatable Read or Serializable in Postgres do, what signal triggers it, and why is Serializable described as "non-blocking"?