Transaction isolation levels: an interactive simulator of dirty reads, lost updates and write skew
An isolation level says how much of each other concurrent transactions can see. At the strongest, serializable, the result is always as if the transactions ran one at a time. Weaker levels, repeatable read, read committed and read uncommitted, let more run at once but allow anomalies: reads of uncommitted data, values that change mid-transaction, updates that vanish and rules that break.
The playground runs two transactions on a fixed schedule in a small multi-version database. Pick an anomaly and a level, step through what each statement returns, and compare every combination in the table.
Read committed: each statement sees what was committed before it began, so two statements in one transaction can see different data. Lost update (one of two concurrent updates silently overwriting the other): it happens. Two likes, but the count went from 10 to 11: T2 overwrote T1’s update with a value computed from a stale read.
SELECT likes FROM posts WHERE id = 1→ 10SELECT likes FROM posts WHERE id = 1→ 10UPDATE posts SET likes = 11 WHERE id = 1UPDATE 1COMMITcommittedUPDATE posts SET likes = 11 WHERE id = 1UPDATE 1COMMITcommittedLevels as PostgreSQL implements them; read uncommitted reads uncommitted rows as in MySQL and SQL Server. The SQL standard lets repeatable read show phantoms, and MySQL’s repeatable read lets the lost update through. Click a cell to run it.
The five anomalies
| Anomaly | What goes wrong | Prevented from |
|---|---|---|
| Dirty read | Reading a change another transaction has not committed, and may roll back | Read committed |
| Non-repeatable read | Reading the same row twice and getting different values | Repeatable read |
| Phantom read | Running the same query twice and getting new rows | Repeatable read in PostgreSQL; serializable by the SQL standard |
| Lost update | Two read-modify-write cycles on one row, one overwriting the other | Repeatable read in PostgreSQL; serializable in MySQL |
| Write skew | Two transactions read the same rows, update different ones, and together break a rule each checked | Serializable |
What each level does
Most databases implement isolation with multi-version concurrency control: an update writes a new version of the row, and each reader picks the version its snapshot allows. Read committed takes a new snapshot for every statement, so a second read can see another transaction’s commit. Repeatable read, called snapshot isolation in the literature, keeps one snapshot for the whole transaction; in PostgreSQL, updating a row that changed after that snapshot fails with a serialization error, which is what stops the lost update in the playground. Serializable in PostgreSQL adds serializable snapshot isolation: it tracks what each transaction read and aborts one when two transactions each read something the other wrote, the pattern behind write skew. Other databases get serializability with locks instead, blocking rather than aborting.
Defaults differ. PostgreSQL, SQL Server and Oracle default to read committed and MySQL to repeatable read. Names differ too: Oracle’s serializable is snapshot isolation and allows write skew, and MySQL’s repeatable read lets lost updates through, because its updates read the latest row, not the snapshot.
Fixing anomalies without serializable
Most applications run at read committed and close the specific gaps that matter. Do the read-modify-write in one statement, lock the rows you read with SELECT ... FOR UPDATE, add a version column for optimistic locking, or let a unique or exclusion constraint enforce the rule:
-- lost update, fixed at read committed: let the database do the read-modify-write
UPDATE posts SET likes = likes + 1 WHERE id = 1;
-- or lock the row you read until you commit
SELECT likes FROM posts WHERE id = 1 FOR UPDATE;
-- write skew, fixed by locking every row the rule depends on
SELECT name FROM doctors WHERE on_call FOR UPDATE;
-- double booking, fixed by a constraint instead of a check (needs btree_gist)
ALTER TABLE bookings ADD CONSTRAINT no_overlap
EXCLUDE USING gist (room WITH =, during WITH &&);Or run the transactions that need it at serializable and retry on serialization failures. The whole transaction has to be retried, reads included, and it should be short:
from psycopg import errors
def transfer(conn, src, dst, amount, tries=5):
for attempt in range(tries):
try:
with conn.transaction():
conn.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
(balance,) = conn.execute(
"SELECT balance FROM accounts WHERE id = %s", (src,)).fetchone()
if balance < amount:
raise ValueError("insufficient funds")
conn.execute("UPDATE accounts SET balance = balance - %s WHERE id = %s", (amount, src))
conn.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s", (amount, dst))
return
except errors.SerializationFailure: # SQLSTATE 40001: rerun the whole transaction
continue
raise RuntimeError("gave up after repeated serialization failures")In a system design interview
Name the anomaly you are protecting against rather than just a level: “Bookings run at read committed, and an exclusion constraint on room and time range makes double booking impossible whatever the interleaving. Balance transfers lock both account rows in id order with SELECT ... FOR UPDATE, so they serialize without deadlocks. The reporting queries use repeatable read so a long report sees one consistent snapshot.” Expect the follow-ups: what happens across shards, where a single database’s isolation no longer applies, and how retries interact with side effects such as sending an email.
Read the guide to transactions and isolation or ACID vs BASE, see two-phase commit for transactions across databases, or watch a strong candidate design a ticketing system.
Questions people ask
What are transaction isolation levels?
Settings that decide how much concurrent transactions see of each other. The SQL standard defines read uncommitted, read committed, repeatable read and serializable. Weaker levels allow more concurrency but permit anomalies such as dirty reads, non-repeatable reads, phantoms, lost updates and write skew; serializable allows none.
What is the difference between read committed and repeatable read?
Read committed gives each statement a fresh view of committed data, so reading the same row twice in one transaction can return different values. Repeatable read keeps one snapshot for the whole transaction, so repeated reads agree, and in PostgreSQL an update to a row changed since that snapshot fails instead of overwriting it.
What is write skew?
An anomaly where two transactions read the same data, each makes a decision that is safe on its own, and they update different rows, so together they break a rule. The classic case: two on-call doctors each check that someone else is on call and both go off call. Snapshot isolation allows it; serializable prevents it.
What is a lost update and how do you prevent it?
Two transactions read the same value, both compute a new value from it and write it back, and the second write erases the first. Prevent it with an atomic update such as SET likes = likes + 1, by locking the row with SELECT FOR UPDATE, with a version check for optimistic locking, or with an isolation level that detects the conflict.
What is the default isolation level in PostgreSQL and MySQL?
PostgreSQL defaults to read committed, as do SQL Server and Oracle. MySQL with InnoDB defaults to repeatable read. The same name can mean different guarantees: MySQL’s repeatable read allows lost updates, and PostgreSQL’s repeatable read does not.
Is snapshot isolation the same as serializable?
No. Snapshot isolation gives each transaction a consistent snapshot and stops two transactions updating the same row, but it allows write skew. Oracle’s serializable level is snapshot isolation. PostgreSQL’s serializable adds serializable snapshot isolation, which detects the dangerous read-write patterns and aborts a transaction.
Why do serializable transactions need retries?
Because the database prevents anomalies by aborting one of the conflicting transactions with a serialization failure, SQLSTATE 40001, rather than blocking forever. The application must rerun the whole transaction, including its reads, so serializable transactions should be short, free of side effects until commit, and wrapped in a retry loop.