SQL Isolation Levels Explained: Dirty, Non-Repeatable, Phantom
The four SQL isolation levels and the anomalies each allows: dirty, non-repeatable and phantom reads, and why MySQL and PostgreSQL pick different defaults.
- Course: SQL study plan
- Module: Transactions and concurrency
- Kind: Lesson
- Reading time: 16 min
- Runtime: MySQL 8 syntax on SQLite 3.49
What are the isolation levels in SQL?
The SQL standard defines four transaction isolation levels: READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ and SERIALIZABLE. Each forbids more read anomalies than the one before. READ UNCOMMITTED allows dirty reads, READ COMMITTED prevents them, REPEATABLE READ also prevents non-repeatable reads, and SERIALIZABLE prevents phantoms too. MySQL's InnoDB defaults to REPEATABLE READ; PostgreSQL defaults to READ COMMITTED.
Lesson
The I in ACID promises that concurrent transactions do not interfere. Taken literally that means serializability: the result is the same as if the transactions had run one after another in some order. Serializable execution is expensive, so databases let you choose a weaker isolation level that allows specific, named anomalies in exchange for concurrency. This lesson defines the anomalies with concrete schedules, gives the standard table that maps levels to anomalies — the most-asked transaction question in interviews — and explains how MySQL's and PostgreSQL's defaults actually behave.
Schedules
A schedule is the interleaved order in which steps of concurrent transactions run. The examples use two sessions, T1 and T2, on this table:
CREATE TABLE accounts (id INT PRIMARY KEY, owner VARCHAR(40), balance INT);
-- id 1, Asha, 1000
Dirty read
T1 reads a value T2 has written but not committed.
T1 at READ UNCOMMITTED; accounts id 1, balance 1000- T2 opens a transaction on Asha's account, whose committed balance is 1000. T1 runs under READ UNCOMMITTED.
- T2 sets the balance to 0 but has not committed. The committed value is still 1000; the 0 exists only inside T2.
- T1 reads the newest version, committed or not, and sees 0: a dirty read of a write that may never happen.
- T2 rolls back. The balance was 1000 all along in every committed state, so the 0 T1 acted on never existed. READ COMMITTED and above prevent this.
T1 has read a balance of 0 that never existed in any committed state. If T1 refused a loan because of it, the decision was based on fiction.
Non-repeatable read
T1 reads the same row twice and gets different values, because T2 updated it and committed in between.
T1 at READ COMMITTED; accounts id 1, balance 1000- T1 starts a transaction under READ COMMITTED, where every statement reads the latest committed data.
- T1's first read sees the committed 1000.
- T2 updates the balance to 700. Uncommitted, so T1 could not see it even if it read now.
- T2 commits: 700 is now the committed balance.
- T1 reads the same row again and gets 700, not 1000. Nothing dirty was read, yet one transaction saw two values — REPEATABLE READ keeps one snapshot and prevents it.
Nothing uncommitted was read, yet T1's view of the world changed under it — a report that reads the balance in two places would contradict itself.
Phantom read
T1 runs the same query twice and gets a different set of rows, because T2 inserted (or deleted) rows that match and committed.
T1 at READ COMMITTED; COUNT(*) WHERE balance > 500- T1 starts. Of the 4 accounts, 3 hold more than 500.
- T1 counts the accounts over 500: 3.
- T2 inserts Zoya's account with 900 — a row that matches T1's condition but did not exist when T1 read. Not yet committed.
- T2 commits, and the new row is part of the committed data.
- T1 runs the same query and gets 4. No row it saw changed; a new one appeared — a phantom. Locking the rows read cannot stop it; preventing it means locking the range, gaps included.
No row T1 had seen changed; a new one appeared. Locking the rows you read cannot stop this, because the phantom did not exist when you read — preventing it means locking the range, gaps included.
The standard table
| Isolation level | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
READ UNCOMMITTED | possible | possible | possible |
READ COMMITTED | prevented | possible | possible |
REPEATABLE READ | prevented | prevented | possible (by the standard) |
SERIALIZABLE | prevented | prevented | prevented |
Learn it row by row: each level forbids one more anomaly than the one above. "Possible" means the standard allows it; a particular engine may do better.
Setting the level in MySQL
SET TRANSACTION ISOLATION LEVEL READ COMMITTED; -- the next transaction only
START TRANSACTION;
-- …
COMMIT;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- this connection from now on
SELECT @@transaction_isolation; -- 'REPEATABLE-READ'
The server-wide default comes from the transaction_isolation system variable. (tx_isolation was its name until MySQL 5.7 and is gone in 8.0.)
How InnoDB actually behaves
READ UNCOMMITTED reads the newest version of every row, committed or not. Almost never used.
READ COMMITTED gives every statement a fresh snapshot of committed data: no dirty reads, but two statements in one transaction can disagree. Locking reads and updates lock only the rows they touch (no gap locks), so concurrency is higher. Many teams running MySQL with row-based replication choose it.
REPEATABLE READ — the InnoDB default — takes one snapshot at the transaction's first read and uses it for every plain SELECT until commit. Repeated reads agree, and a repeated plain SELECT does not see phantoms either, because it reads the same snapshot. Locking statements (SELECT … FOR UPDATE, UPDATE, DELETE) work differently: they read the latest committed row versions and take next-key locks — locks on the index records and the gaps between them — so no other transaction can insert into the scanned range until commit. That is how InnoDB closes the phantom hole the standard leaves open for this level, for locking reads.
The mix has a famous edge: within one REPEATABLE READ transaction, a plain SELECT may say a row does not exist while an UPDATE of it succeeds, because the update reads the latest version, not the snapshot.
SERIALIZABLE in InnoDB is REPEATABLE READ plus one rule: when autocommit is off, every plain SELECT becomes SELECT … FOR SHARE, so reads take shared locks and conflicting writers wait. Correct, and much more prone to waits and deadlocks.
PostgreSQL, for comparison
- The default is
READ COMMITTED, as in Oracle and SQL Server. READ UNCOMMITTEDbehaves asREAD COMMITTED: PostgreSQL never shows dirty data.REPEATABLE READis snapshot isolation: no phantoms at all, and if two transactions update the same row the second fails with a serialization error that the application must retry — where InnoDB would let it overwrite the newer version.SERIALIZABLEuses serializable snapshot isolation (SSI): no read locks, but it detects dangerous patterns and aborts one transaction.
Beyond the table: lost updates and write skew
The three classic anomalies are not the whole story. Two more come up in system-design interviews:
- Lost update: T1 and T2 both read a stock of 10, both write
10 - 1, and one sale disappears. Fix it with an atomicUPDATE … SET stock = stock - 1, a locking read (SELECT … FOR UPDATE), or a version check (optimistic locking, next lesson). - Write skew: two doctors are on call; each transaction checks "at least one other doctor is on call" and takes itself off. Each read was valid, they wrote different rows, and now nobody is on call. Snapshot isolation allows it;
SERIALIZABLEor locking the rows you based the decision on prevents it.
Choosing a level
Most applications run fine on their database's default and use explicit locking or atomic updates for the few places that need stronger guarantees — a seat booking, a balance check. Raise the level for a whole workload only when you understand the extra waits and retries it brings.
Practise
Concurrency cannot run on a single connection, but schedules can be stored and analysed. Both exercises read a schedule table — one row per step: which transaction, what action, which row, which value — and detect anomalies in SQL: the first finds dirty reads, the second non-repeatable reads.
Common questions
What is a dirty read?
A dirty read happens when a transaction reads a value another transaction has written but not yet committed. If the writer then rolls back, the reader has acted on data that never existed. Only READ UNCOMMITTED allows dirty reads; every other level shows only committed data.
What is the difference between a non-repeatable read and a phantom read?
A non-repeatable read is the same row read twice in one transaction returning different values, because another transaction updated and committed it in between. A phantom read is the same query returning a different set of rows, because another transaction inserted or deleted matching rows. One concerns changed rows, the other new or vanished rows.
What is the default isolation level in MySQL?
REPEATABLE READ is the default for InnoDB. Plain SELECTs in a transaction read one consistent snapshot taken at the first read, so repeated reads agree, and locking reads use next-key locks that block inserts into the scanned range. PostgreSQL, Oracle and SQL Server default to READ COMMITTED instead.
How do I change the isolation level in MySQL?
Run SET TRANSACTION ISOLATION LEVEL READ COMMITTED before START TRANSACTION to change it for the next transaction only, or SET SESSION TRANSACTION ISOLATION LEVEL to change it for the connection. SELECT @@transaction_isolation shows the current level; the old tx_isolation variable was removed in MySQL 8.0.
What is write skew?
Write skew is an anomaly where two transactions read the same data, each makes a decision from it and writes to different rows, and together they break a rule neither broke alone, such as two doctors both going off call. Snapshot-based REPEATABLE READ allows it; only SERIALIZABLE or explicit locking with SELECT ... FOR UPDATE prevents it.
Exercises
Spot the dirty reads
schedule records an interleaving of transactions, one row per step: action is begin, read, write, commit or rollback; item and seen_value are set on reads and writes. A read is dirty when another transaction wrote the same item earlier and had neither committed nor rolled back by the time of the read. List every dirty read with the transaction it read from.
schedule:
| step | txn | action | item | seen_value |
|---|---|---|---|---|
| 1 | T1 | begin | NULL | NULL |
| 2 | T2 | begin | NULL | NULL |
| 3 | T2 | write | A | 0 |
| 4 | T1 | read | A | 0 |
| 5 | T2 | rollback | NULL | NULL |
| 6 | T1 | read | A | 1000 |
| 7 | T3 | begin | NULL | NULL |
| 8 | T3 | write | B | 50 |
| 9 | T3 | commit | NULL | NULL |
| 10 | T1 | read | B | 50 |
| 11 | T1 | commit | NULL | NULL |
Output columns: step (of the read), reader, item, writer — one row per distinct read and writer — ordered by step, then writer.
step | reader | item | writer
4 | T1 | A | T2Find the non-repeatable reads
Using the same kind of schedule table, find non-repeatable reads: a transaction read the same item more than once and saw more than one value, without having written that item itself (seeing your own write is not an anomaly).
schedule:
| step | txn | action | item | seen_value |
|---|---|---|---|---|
| 1 | T1 | begin | NULL | NULL |
| 2 | T1 | read | A | 1000 |
| 3 | T2 | begin | NULL | NULL |
| 4 | T2 | write | A | 700 |
| 5 | T2 | commit | NULL | NULL |
| 6 | T1 | read | A | 700 |
| 7 | T1 | read | B | 40 |
| 8 | T3 | begin | NULL | NULL |
| 9 | T3 | read | B | 40 |
| 10 | T3 | write | B | 45 |
| 11 | T3 | read | B | 45 |
| 12 | T3 | commit | NULL | NULL |
| 13 | T1 | read | B | 45 |
| 14 | T1 | read | A | 700 |
| 15 | T1 | commit | NULL | NULL |
Output columns: txn, item, times_read (how many times it read the item), distinct_values, ordered by txn, then item.
txn | item | times_read | distinct_values
T1 | A | 3 | 2
T1 | B | 2 | 2