The anomaly catalog
What each MySQL isolation level does about every named anomaly, and the proof. The anomalies themselves (definitions, diagrams, and where the G-codes come from) live in Concepts: the anomaly catalog; this page is MySQL's answer sheet, covering every case Hermitage tests — including the rows where MySQL's answer differs from PostgreSQL's.
The short version: on MySQL, isolation levels protect reads, not read-modify-write cycles, so the ⚠️ cells in the bottom half of this table get fixed with locks or SQL arithmetic rather than the isolation knob. The REPEATABLE READ default still loses updates and current reads see phantoms, while SERIALIZABLE closes the rest with locks — so plan to retry on 1213.
| Code | Anomaly | READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ (default) | SERIALIZABLE |
|---|---|---|---|---|---|
| G0 | Dirty write | ✅ impossible — proof | ✅ | ✅ | ✅ |
| G1a | Dirty read (aborted read) | ⚠️ happens | ✅ impossible | ✅ | ✅ |
| G1b | Intermediate read | ⚠️ happens | ✅ impossible — proof | ✅ | ✅ |
| G1c | Circular information flow | ⚠️ happens | ✅ impossible — proof | ✅ | ✅ |
| OTV | Observed transaction vanishes | ⚠️ happens | ✅ impossible — proof | ✅ | ✅ |
| P2 | Non-repeatable read | ⚠️ | ⚠️ happens | ✅ prevented (plain SELECTs) — proof | ✅ |
| G-single | Read skew | ⚠️ | ⚠️ happens | ⚠️ plain SELECTs safe — but writes aren't | ✅ |
| PMP | Phantom read | ⚠️ | ⚠️ happens | ⚠️ plain SELECTs safe — current reads see phantoms | ✅ |
| P4 | Lost update | ⚠️ | ⚠️ happens | ⚠️ still happens — proof | ✅ prevented (locks + deadlock) |
| G2-item / G2 | Write skew (item & predicate) | ⚠️ | ⚠️ | ⚠️ happens | ✅ prevented — proof |
The MySQL-specific pattern
Reading this table column by column, one theme emerges: on MySQL, isolation levels protect reads, not read-modify-write cycles. REPEATABLE READ gives your SELECTs a perfectly stable world — and your UPDATEs a completely different, current one. Everything in the bottom half of the table is fixed with explicit locking or SQL-side arithmetic, not with the isolation knob.
PostgreSQL's catalog looks different: its RR turns lost updates into retryable errors, its SERIALIZABLE detects write skew without locks, and its weakest level is Hermitage-clean — see both engines' answers side by side. The three ⚠️ cells in MySQL's REPEATABLE READ column are exactly where Hermitage's results for the two databases diverge, and all three trace back to current reads.
What READ UNCOMMITTED really costs
The G1 family and OTV are all ✅ from READ COMMITTED up — but unlike PostgreSQL, MySQL really does serve dirty data if you ask for it, so each one is observable:
Dirty writes (G0)
The one guarantee every level keeps: InnoDB always takes exclusive row locks for writes, so interleaved writes can't produce a state no serial order could:
Two batch jobs reprice the whole catalog concurrently — at READ UNCOMMITTED, the weakest level MySQL has. 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> SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
Query OK
B> SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
Query OK
A> BEGIN;
Query OK
B> BEGIN;
Query OK
A> UPDATE items SET price = 11 WHERE id = 1;
Query OK, 1 row affectedB may READ A's uncommitted rows at this level — but it may not overwrite them.
B> UPDATE items SET price = 12 WHERE id = 1;
⏳ B is waiting for a lock…
A> UPDATE items SET price = 21 WHERE id = 2;
Query OK, 1 row affected
A> COMMIT; -- releases both row locks
Query OK
⏵ B resumes:
Query OK, 1 row affected
B> UPDATE items SET price = 22 WHERE id = 2;
Query OK, 1 row affected
B> COMMIT;
Query OK
A> SELECT id, price FROM items ORDER BY id; -- all B — as if B ran after A. Never 12/21 or 11/22.
id | price
----+-------
1 | 12
2 | 22
(2 rows)Verified against MySQL 8.4.10 · Run it yourself · Scenario source
Intermediate reads (G1b)
At READ UNCOMMITTED you don't only read uncommitted data — you read drafts the writer later overwrites, values that are never part of any committed history:
A recalculates alice's balance in two steps. B watches at READ UNCOMMITTED.
B> SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
Query OK
B> BEGIN;
Query OK
A> BEGIN;
Query OK
A> UPDATE accounts SET balance = 999 WHERE id = 1; -- a working draft — A isn't done yet
Query OK, 1 row affected
B> SELECT balance FROM accounts WHERE id = 1; -- B just read a draft
balance
---------
999
(1 row)
A> UPDATE accounts SET balance = 110 WHERE id = 1; -- A settles on the final value…
Query OK, 1 row affected
A> COMMIT;
Query OK
B> SELECT balance FROM accounts WHERE id = 1;
balance
---------
110
(1 row)
B> COMMIT;
Query OKThe committed history is 100 → 110. The 999 B acted on was never true — not even for an instant of committed time. At READ COMMITTED the same watch shows only 100, then 110:
B> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
Query OK
B> BEGIN;
Query OK
A> BEGIN;
Query OK
A> UPDATE accounts SET balance = 555 WHERE id = 1;
Query OK, 1 row affected
B> SELECT balance FROM accounts WHERE id = 1; -- no drafts served here
balance
---------
110
(1 row)
A> ROLLBACK;
Query OK
B> COMMIT;
Query OKVerified against MySQL 8.4.10 · Run it yourself · Scenario source
Circular information flow (G1c)
Two transactions reading each other's uncommitted writes — an exchange with no serial explanation, and gone at READ COMMITTED:
A adjusts alice while B adjusts bob — then each peeks at the other's row. In any serial order, at most one of them can see the other's write.
A> SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
Query OK
B> SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
Query OK
A> BEGIN;
Query OK
B> BEGIN;
Query OK
A> UPDATE accounts SET balance = 111 WHERE id = 1;
Query OK, 1 row affected
B> UPDATE accounts SET balance = 222 WHERE id = 2;
Query OK, 1 row affected
A> SELECT balance FROM accounts WHERE id = 2; -- A sees B's uncommitted write…
balance
---------
222
(1 row)
B> SELECT balance FROM accounts WHERE id = 1; -- …and B sees A's. Information flowed in a circle.
balance
---------
111
(1 row)Neither value was committed when it was read — if either side now rolls back, the other has computed on data that never existed. Both roll back:
A> ROLLBACK;
Query OK
B> ROLLBACK;
Query OKThe same dance at READ COMMITTED: each sees the world before the other.
A> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
Query OK
B> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
Query OK
A> BEGIN;
Query OK
B> BEGIN;
Query OK
A> UPDATE accounts SET balance = 111 WHERE id = 1;
Query OK, 1 row affected
B> UPDATE accounts SET balance = 222 WHERE id = 2;
Query OK, 1 row affected
A> SELECT balance FROM accounts WHERE id = 2;
balance
---------
100
(1 row)
B> SELECT balance FROM accounts WHERE id = 1;
balance
---------
100
(1 row)
A> COMMIT;
Query OK
B> COMMIT;
Query OKVerified against MySQL 8.4.10 · Run it yourself · Scenario source
Observed transaction vanishes (OTV)
At READ UNCOMMITTED a committed transaction can be visible in pieces — one row already overwritten by an uncommitted successor, the other still showing through:
A rewrites both balances and commits. B overwrites one of them. C watches — dirtily.
C> SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
Query OK
C> BEGIN;
Query OK
A> BEGIN;
Query OK
A> UPDATE accounts SET balance = 11 WHERE id = 1;
Query OK, 1 row affected
A> UPDATE accounts SET balance = 19 WHERE id = 2;
Query OK, 1 row affected
B> BEGIN;
Query OK
B> UPDATE accounts SET balance = 12 WHERE id = 1;
⏳ B is waiting for a lock…
A> COMMIT; -- A is committed — its writes are 11 and 19
Query OK
⏵ B resumes:
Query OK, 1 row affected
C> SELECT id, balance FROM accounts ORDER BY id; -- 12 is B's uncommitted draft, 19 is A's commit — A's 11 already vanished
id | balance
----+---------
1 | 12
2 | 19
(2 rows)C never saw the committed state {11, 19}. Half of A was overwritten before C ever observed it — as far as C can tell, A only ever wrote one row. B now finishes:
B> UPDATE accounts SET balance = 18 WHERE id = 2;
Query OK, 1 row affected
B> COMMIT;
Query OK
C> SELECT id, balance FROM accounts ORDER BY id;
id | balance
----+---------
1 | 12
2 | 18
(2 rows)
C> COMMIT;
Query OKREAD COMMITTED never shows a committed transaction in pieces:
C> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
Query OK
C> BEGIN;
Query OK
A> BEGIN;
Query OK
A> UPDATE accounts SET balance = 51 WHERE id = 1;
Query OK, 1 row affected
A> UPDATE accounts SET balance = 59 WHERE id = 2;
Query OK, 1 row affected
C> SELECT id, balance FROM accounts ORDER BY id; -- all of the last committed state — nothing of A's draft
id | balance
----+---------
1 | 12
2 | 18
(2 rows)
A> COMMIT;
Query OK
C> SELECT id, balance FROM accounts ORDER BY id; -- and now all of A, atomically
id | balance
----+---------
1 | 51
2 | 59
(2 rows)
C> COMMIT;
Query OKVerified against MySQL 8.4.10 · Run it yourself · Scenario source
Error codes to retry on
| errno | SQLSTATE | Meaning | Where you saw it |
|---|---|---|---|
1213 | 40001 | Deadlock found; transaction rolled back | SERIALIZABLE write skew, deadlocks |
1205 | HY000 | Lock wait timeout; statement rolled back, transaction survives | lock timeouts |
Further reading
- Hermitage — runnable isolation tests for MySQL, PostgreSQL, Oracle, and more; this chapter proves every MySQL case it covers
- MySQL docs: Transaction Isolation Levels
- Anomalies by engine — both engines' answers collapsed to one cell each, in adjacent columns
- The same catalog for PostgreSQL — the full grid