Repeatable Read
At REPEATABLE READ — MySQL's default — the first read of the transaction takes one snapshot, and every later plain SELECT reads from that same frozen view: no non-repeatable reads, no phantoms.
But only plain SELECTs. UPDATE, DELETE, and SELECT … FOR UPDATE/SHARE are current reads: they operate on the latest committed data, snapshot be damned. That asterisk is where MySQL's RR differs most from PostgreSQL's — and where ported assumptions break.
One snapshot, no phantoms
A> BEGIN;
Query OKThe snapshot is taken by the FIRST query, not by BEGIN.
A> SELECT id, balance FROM accounts ORDER BY id;
id | balance
----+---------
1 | 100
2 | 50
(2 rows)B changes an existing row AND inserts a new one — both committed instantly.
B> UPDATE accounts SET balance = 999 WHERE id = 1;
Query OK, 1 row affected
B> INSERT INTO accounts VALUES (3, 'carol', 300);
Query OK, 1 row affected
A> SELECT id, balance FROM accounts ORDER BY id; -- not 999 — no non-repeatable read; and no carol — no phantom
id | balance
----+---------
1 | 100
2 | 50
(2 rows)
A> COMMIT;
Query OKOnly a NEW transaction gets a new snapshot.
A> SELECT id, balance FROM accounts ORDER BY id;
id | balance
----+---------
1 | 999
2 | 50
3 | 300
(3 rows)Verified against MySQL 8.4.10 · Run it yourself · Scenario source
Current reads punch holes in the snapshot
Watch a single transaction read 100, then compute +50 from a value it has never seen, then suddenly see 200:
A> BEGIN;
Query OK
A> SELECT balance FROM accounts WHERE id = 1; -- snapshot taken
balance
---------
100
(1 row)B commits a change. A keeps READING its stale snapshot…
B> UPDATE accounts SET balance = 150 WHERE id = 1;
Query OK, 1 row affected
A> SELECT balance FROM accounts WHERE id = 1;
balance
---------
100
(1 row)…but A's UPDATE is a current read: it computes from B's committed 150, not from the snapshot's 100.
A> UPDATE accounts SET balance = balance + 50 WHERE id = 1;
Query OK, 1 row affected
A> SELECT balance FROM accounts WHERE id = 1; -- 150 + 50 — and now A sees it: the snapshot has a hole
balance
---------
200
(1 row)
A> COMMIT;
Query OKPostgreSQL would have aborted A's UPDATE with 40001 instead. MySQL quietly switches world views.
If the competing write is NOT yet committed, A first waits on the row lock…
A> BEGIN;
Query OK
A> SELECT balance FROM accounts WHERE id = 1; -- snapshot taken
balance
---------
200
(1 row)
B> BEGIN;
Query OK
B> UPDATE accounts SET balance = 300 WHERE id = 1;
Query OK, 1 row affected
A> UPDATE accounts SET balance = balance + 50 WHERE id = 1;
⏳ A is waiting for a lock……and proceeds from B's value the moment B commits. No error here either.
B> COMMIT;
Query OK
⏵ A resumes:
Query OK, 1 row affected
A> SELECT balance FROM accounts WHERE id = 1; -- 300 + 50
balance
---------
350
(1 row)
A> COMMIT;
Query OKVerified against MySQL 8.4.10 · Run it yourself · Scenario source
Porting from PostgreSQL?
PostgreSQL's REPEATABLE READ refuses to update a row that changed after your snapshot (SQLSTATE 40001, see the PostgreSQL lesson). MySQL never raises that error: the write goes through against the current version. Retry loops written for PostgreSQL have nothing to catch here — and lost updates that PostgreSQL would have blocked go undetected.
Your DELETE and your SELECT live in different worlds
Current reads get truly disorienting when a predicate is involved. A transaction can "delete every row matching X", delete nothing — the current data no longer matches — and then keep seeing snapshot rows that match X (Hermitage calls this the write-predicate variant of G-single):
A opens a snapshot. B then reshuffles every balance and commits.
A> BEGIN;
Query OK
A> SELECT id, balance FROM accounts ORDER BY id; -- snapshot taken
id | balance
----+---------
1 | 10
2 | 20
(2 rows)
B> BEGIN;
Query OK
B> UPDATE accounts SET balance = balance + 5;
Query OK, 2 rows affected
B> COMMIT; -- current data is now 15 and 25
Query OKA closes every account with balance 20 — its snapshot says that's bob. But the DELETE is a current read: it scans the committed 15 and 25, finds nothing, and deletes nothing.
A> DELETE FROM accounts WHERE balance = 20;
Query OK, 0 rows affected
A> SELECT id, balance FROM accounts WHERE balance = 20 ORDER BY id; -- …yet A still SEES a row with balance 20. Deleted: no such rows. Visible: one.
id | balance
----+---------
2 | 20
(1 row)
A> COMMIT;
Query OKA's writes ran in one world, its reads in another. PostgreSQL's REPEATABLE READ aborts the DELETE with a serialization failure instead; on MySQL the cure is a locking read (FOR UPDATE) or SERIALIZABLE.
Verified against MySQL 8.4.10 · Run it yourself · Scenario source
REPEATABLE READ gives you one snapshot per transaction, taken by the first read rather than by BEGIN, and plain SELECTs stay phantom-free — stronger than the SQL standard asks for. Writes and locking reads bypass that snapshot as current reads, and once you modify a row your own SELECTs see the new version, so the snapshot is a default, not a wall. No 40001-style serialization errors fire at this level, which leaves the anomalies RR can't stop — lost updates and write skew — to be handled with locking reads or constraints.