Skip to content

The anomaly catalog

What each PostgreSQL isolation level does about every named anomaly, and — in the spirit of this site — a link to the scenario that proves each cell. The anomalies themselves (definitions, diagrams, and where the G-codes come from) live in Concepts: the anomaly catalog; this page is PostgreSQL's answer sheet, covering every case Hermitage tests. READ UNCOMMITTED is omitted: in PostgreSQL it behaves exactly like READ COMMITTED.

The short version, before the table spells out every cell: on the default READ COMMITTED the whole G1 family is already impossible, but lost updates are on you. REPEATABLE READ adds a stable snapshot and refuses stale writes, as long as you retry on 40001. And invariants that span rows — write skew — only become automatic at SERIALIZABLE (proof).

CodeAnomalyREAD COMMITTED (default)REPEATABLE READSERIALIZABLE
G0Dirty write✅ impossible — proof✅ impossible✅ impossible
G1aDirty read (aborted read)✅ impossible — proof✅ impossible✅ impossible
G1bIntermediate read✅ impossible — proof✅ impossible✅ impossible
G1cCircular information flow✅ impossible — proof✅ impossible✅ impossible
OTVObserved transaction vanishes✅ impossible — proof✅ impossible✅ impossible
P2Non-repeatable read⚠️ happens✅ prevented — proof✅ prevented
G-singleRead skew⚠️ happens✅ prevented — proof✅ prevented
PMPPhantom read⚠️ happens✅ prevented in PostgreSQL — proof (standard would allow it)✅ prevented
P4Lost update⚠️ silent✅ rejected with 40001proof✅ rejected with 40001
G2-item / G2Write skew (item & predicate)⚠️ possible¹⚠️ happens✅ rejected with 40001proof
Read-only anomaly (Fekete et al.)—¹⚠️ happens✅ rejected with 40001proof

¹ These two anomalies are defined against stable snapshots. READ COMMITTED doesn't provide snapshot stability in the first place — it's exposed to everything REPEATABLE READ is, plus the rows themselves can shift mid-transaction (the RR scenarios are the demonstrations of the strictly-stronger level).

How to use this table

Staying on the default? Then treat lost updates as your problem to solve, fixing read-modify-write code with atomic updates, FOR UPDATE, or version columns (fixing lost updates). Invariants that span multiple rows ("at least one on call", "the sum must stay positive", "unique-ish under concurrency") are only automatic at SERIALIZABLE; anything weaker needs explicit locking. And anything running at REPEATABLE READ or SERIALIZABLE has to retry on SQLSTATE 40001 (serialization_failure); spot that error being swallowed in your logs and you've found a bug.

The guarantees you get for free

The first five rows of the table are all ✅ — every PostgreSQL isolation level provides them. They're worth seeing once, because other databases (and Hermitage's weaker rows for them) show these can genuinely fail elsewhere.

Dirty writes (G0)

Two transactions interleave writes to the same rows. Row locks force the second writer to wait, so the result is always one transaction's writes, never a mix:

A
B
UPDATE id=1 price=11
UPDATE id=1 price=12→ ⏳ waits
UPDATE id=2 price=21
COMMIT← locks released, B wakes up
⏵ UPDATE id=1 price=12→ completes
UPDATE id=2 price=22
COMMIT
SELECT→ 12, 22 ← all B, never a mix

Two batch jobs reprice the whole catalog concurrently. A mix of their prices (A's mug with B's cap) would be a dirty write — a state no serial order could produce.

A> BEGIN;
BEGIN

B> BEGIN;
BEGIN

A> UPDATE items SET price = 11 WHERE id = 1;
UPDATE 1

B wants the same row. Even at READ COMMITTED it must wait for A's lock.

B> UPDATE items SET price = 12 WHERE id = 1;
⏳ B is waiting for a lock…

A> UPDATE items SET price = 21 WHERE id = 2;
UPDATE 1

A> COMMIT; -- releases both row locks
COMMIT

⏵ B resumes:
UPDATE 1

B> UPDATE items SET price = 22 WHERE id = 2;
UPDATE 1

B> COMMIT;
COMMIT

A> SELECT id, price FROM items ORDER BY id; -- all B — as if B had run after A. Never 12/21 or 11/22.
 id | price 
----+-------
  1 |    12 
  2 |    22 
(2 rows)

Verified against PostgreSQL 18.4 · Run it yourself · Scenario source

Intermediate reads (G1b)

A transaction that changes a value twice never leaks the draft — readers see only final, committed states:

A
B
UPDATE balance = 999 (draft, uncommitted)
SELECT→ 100 ← draft 999 invisible
UPDATE balance = 110 (final value)
COMMIT
SELECT→ 110 ← only the final value, never 999
A> BEGIN;
BEGIN

A> UPDATE accounts SET balance = 999 WHERE id = 1; -- a working draft — A isn't done yet
UPDATE 1

B> SELECT balance FROM accounts WHERE id = 1; -- the draft 999 is invisible
 balance 
---------
     100 
(1 row)

A> UPDATE accounts SET balance = 110 WHERE id = 1; -- A settles on the final value…
UPDATE 1

A> COMMIT;
COMMIT

B> SELECT balance FROM accounts WHERE id = 1;
 balance 
---------
     110 
(1 row)

To every other transaction, the balance went 100 → 110 in one step. The intermediate 999 never existed outside A.

Verified against PostgreSQL 18.4 · Run it yourself · Scenario source

Circular information flow (G1c)

Two concurrent transactions can never each read the other's uncommitted writes — that exchange has no serial explanation:

A
B
UPDATE alice = 111 (uncommitted)
UPDATE bob = 222 (uncommitted)
SELECT bob→ 100 ← B's 222 invisible
SELECT alice→ 100 ← A's 111 invisible
COMMIT
COMMIT
SELECT→ 111, 222 ← both writes, no cross-read

A adjusts alice's balance while B adjusts bob's — then each peeks at the other's row. If both saw the other's uncommitted write, information would flow in a circle: A → B → A. No serial order can do that.

A> BEGIN;
BEGIN

B> BEGIN;
BEGIN

A> UPDATE accounts SET balance = 111 WHERE id = 1;
UPDATE 1

B> UPDATE accounts SET balance = 222 WHERE id = 2;
UPDATE 1

A> SELECT balance FROM accounts WHERE id = 2; -- B's uncommitted 222 is invisible to A
 balance 
---------
     100 
(1 row)

B> SELECT balance FROM accounts WHERE id = 1; -- and A's uncommitted 111 is invisible to B
 balance 
---------
     100 
(1 row)

A> COMMIT;
COMMIT

B> COMMIT;
COMMIT

A> SELECT id, balance FROM accounts ORDER BY id; -- both writes landed — but neither transaction ever saw the other's
 id | balance 
----+---------
  1 |     111 
  2 |     222 
(2 rows)

Verified against PostgreSQL 18.4 · Run it yourself · Scenario source

Observed transaction vanishes (OTV)

Once a transaction commits, readers see all of it or none of it — even while a third transaction is busy overwriting half of its rows:

A
B
C
UPDATE alice = 11
UPDATE bob = 19
UPDATE alice = 12→ ⏳ waits
COMMIT← lock released, B proceeds
⏵ UPDATE alice = 12→ completes
SELECT→ 11, 19 ← all of A, together
UPDATE bob = 18 (uncommitted)
SELECT→ 11, 19 ← B's drafts don't count
COMMIT
SELECT→ 12, 18 ← now all of B, atomically

A rewrites both balances. B will overwrite one of them right after.

A> BEGIN;
BEGIN

A> UPDATE accounts SET balance = 11 WHERE id = 1;
UPDATE 1

A> UPDATE accounts SET balance = 19 WHERE id = 2;
UPDATE 1

B> BEGIN;
BEGIN

B> UPDATE accounts SET balance = 12 WHERE id = 1;
⏳ B is waiting for a lock…

A> COMMIT; -- A's lock released — B's overwrite of alice proceeds
COMMIT

⏵ B resumes:
UPDATE 1

C now watches from the side. B holds an uncommitted overwrite of alice — but A's committed transaction must still be visible in full.

C> SELECT id, balance FROM accounts ORDER BY id; -- A's writes appear together — 11 AND 19
 id | balance 
----+---------
  1 |      11 
  2 |      19 
(2 rows)

B> UPDATE accounts SET balance = 18 WHERE id = 2;
UPDATE 1

C> SELECT id, balance FROM accounts ORDER BY id; -- B's drafts change nothing for C
 id | balance 
----+---------
  1 |      11 
  2 |      19 
(2 rows)

B> COMMIT;
COMMIT

C> SELECT id, balance FROM accounts ORDER BY id; -- only now does C move on — to ALL of B, atomically
 id | balance 
----+---------
  1 |      12 
  2 |      18 
(2 rows)

Verified against PostgreSQL 18.4 · Run it yourself · Scenario source

Further reading

  • Hermitage — runnable isolation tests for PostgreSQL, MySQL, Oracle, and more; this chapter proves every PostgreSQL case it covers
  • Anomalies by engine — this table and MySQL's, collapsed to one cell each and set side by side
  • The same catalog for MySQL — same anomalies, meaningfully different answers

MIT Licensed · Every transcript on this site was generated by a real database run against MySQL 8.4.10 and PostgreSQL 18.4 at bd6f201, and re-proven through psycopg and PyMySQL.