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).
| Code | Anomaly | READ COMMITTED (default) | REPEATABLE READ | SERIALIZABLE |
|---|---|---|---|---|
| G0 | Dirty write | ✅ impossible — proof | ✅ impossible | ✅ impossible |
| G1a | Dirty read (aborted read) | ✅ impossible — proof | ✅ impossible | ✅ impossible |
| G1b | Intermediate read | ✅ impossible — proof | ✅ impossible | ✅ impossible |
| G1c | Circular information flow | ✅ impossible — proof | ✅ impossible | ✅ impossible |
| OTV | Observed transaction vanishes | ✅ impossible — proof | ✅ impossible | ✅ impossible |
| P2 | Non-repeatable read | ⚠️ happens | ✅ prevented — proof | ✅ prevented |
| G-single | Read skew | ⚠️ happens | ✅ prevented — proof | ✅ prevented |
| PMP | Phantom read | ⚠️ happens | ✅ prevented in PostgreSQL — proof (standard would allow it) | ✅ prevented |
| P4 | Lost update | ⚠️ silent | ✅ rejected with 40001 — proof | ✅ rejected with 40001 |
| G2-item / G2 | Write skew (item & predicate) | ⚠️ possible¹ | ⚠️ happens | ✅ rejected with 40001 — proof |
| — | Read-only anomaly (Fekete et al.) | —¹ | ⚠️ happens | ✅ rejected with 40001 — proof |
¹ 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:
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 1B 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> 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 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 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 1C 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