Cruxly

Cruxly › System design › Databases: Choosing One

Isolation levels, in depth

snapshots, write skew and serializability: what your engine really guarantees

Open the interactive lesson

An isolation level is a promise about which anomalies cannot happen, and the name of the level tells you almost nothing about that promise. Two engines at "Repeatable Read" can behave differently, and one engine's "Serializable" can be snapshot isolation. To justify a database choice you need the mechanism behind each level, the anomalies it still allows, and what your application must do (retry, lock, add a constraint) to stay correct.

The anomalies the SQL standard leaves out

SQL-92 defines its four levels with three phenomena: dirty read, non-repeatable read and phantom. Berenson and colleagues showed in 1995 that those definitions are ambiguous and miss several anomalies that real systems exhibit. Their names are still the vocabulary of the field, so learn them.

AnomalyWhat happensWhere it bites
Dirty write (P0)T2 overwrites a value T1 wrote but has not committedTwo-row updates end up half from each writer
Dirty readT2 reads T1's uncommitted valueActing on data that is later rolled back
Read skew (A5A)T1 reads x, T2 changes x and y and commits, T1 reads yReports and backups that do not add up
Lost update (P4)T1 and T2 read, both write, one write vanishesCounters, stock, balances
Write skew (A5B)Both read overlapping rows, each writes a different row, a rule across them breaksOn-call rosters, room bookings
Phantom (P3)A repeated predicate query returns a different set of rowsRange checks, "no overlapping booking"

Two of these deserve a concrete picture because the older lesson on this site does not cover them.

Read skew. Alice and Bob hold 500 each. A transfer moves 100 from Alice to Bob while a report reads both balances:

-- reader                                           -- transfer (one transaction)
SELECT balance FROM accounts WHERE name='alice';    -- 500
                                                    UPDATE accounts SET balance = balance - 100 WHERE name='alice';
                                                    UPDATE accounts SET balance = balance + 100 WHERE name='bob';
                                                    COMMIT;
SELECT balance FROM accounts WHERE name='bob';      -- 600
-- the report adds 500 + 600 = 1100; the bank only ever held 1000

Read Committed allows this, because every statement takes a fresh view of the committed data. Nothing was dirty. Each read was of committed data, but the two reads come from different moments. A long report, a backup or an export is exactly the workload that hits it, and a snapshot is the fix.

Dirty write. Selling a car updates two rows, the buyer and the invoice recipient. If Alice's and Bob's transactions interleave their writes, the buyer ends up Bob while the invoice goes to Alice. Berenson argues every level must prevent this, and mainstream engines do it with a row lock held on the first write until commit, so the second writer waits.

:::key Lost update and write skew The older lesson, Transaction Isolation Levels, covers lost update and the basic on-call write skew. This lesson assumes both and goes into why they happen and what stops them. :::

Use the names precisely when you talk: say "read skew" for the inconsistent pair of reads, "phantom" only when a predicate's row set changes, and "write skew" when the writes hit different rows.

MVCC: how a snapshot actually works

Multi-version concurrency control keeps several versions of a row so a reader can see the data as of one moment without blocking writers. The idea is the same everywhere. The storage differs, and the differences explain operational pain.

The visibility rule. Each transaction gets an id. A snapshot is the set of transactions already committed when the snapshot was taken. A row version is visible to you if the transaction that created it is in your snapshot and the transaction that deleted or replaced it is not.

Where the old versions live:

EngineOld versionsCleanup
PostgreSQLNew version is a new tuple in the table itself, stamped with creating and deleting transaction ids (xmin, xmax)VACUUM removes a version only when no transaction can still see it
MySQL InnoDBRow updated in place; hidden fields point into undo logs, and old versions are rebuilt from undoPurge discards update undo only when no snapshot could need it
OracleUndo segments hold old values; a query rebuilds earlier block versions from undoUndo is reused once it is not needed

When the snapshot is taken is what separates levels. In PostgreSQL Read Committed it is the start of each statement. In Repeatable Read it is the start of the first non-transaction-control statement, held to the end. In InnoDB Repeatable Read it is the first consistent read. In Oracle, SERIALIZABLE and read-only transactions use the time the transaction began.

The cost is an old snapshot. A version cannot be cleaned while any open transaction might still read it. At InnoDB's default Repeatable Read, and at PostgreSQL Repeatable Read or Serializable, one session that runs BEGIN, reads once and then sits idle keeps every version created since. (A PostgreSQL Read Committed session takes a new snapshot per statement, so an idle read-only one does not normally pin old versions between statements, but it still holds its locks and, if it wrote, its transaction id.) In PostgreSQL the dead rows pile up in tables and indexes. In InnoDB the MySQL manual warns that the rollback segment can grow too big and fill the undo tablespace, and it advises committing regularly, even transactions that only read. The first thing to check on a database that bloats is the oldest open transaction (in PostgreSQL, pg_stat_activity and its backend_xmin and backend_xid), and the first guard is a timeout on idle-in-transaction sessions.

What MVCC does not do. Readers do not block writers and writers do not block readers. Two writers on the same row still serialize on a row lock. "MVCC means no locks" is a wrong statement.

:::key What to say "Reads come from a snapshot of old versions, so they never wait. The price is version storage and cleanup, which a long-running transaction can block." :::

Snapshot isolation, and the two things it misses

Snapshot isolation (SI) is the model behind PostgreSQL Repeatable Read, Oracle SERIALIZABLE and SQL Server SNAPSHOT. A transaction reads from a snapshot taken at its start. Writes follow first-committer-wins: if another transaction changed a row after your snapshot and you try to write it too, you lose and must abort. PostgreSQL detects this when you reach the row: if the other transaction is still open you wait for it, and if it committed you get the error could not serialize access due to concurrent update at the UPDATE, not at COMMIT. The application must retry the whole transaction.

SI therefore prevents dirty reads, non-repeatable reads, read skew and (by aborting) lost updates. It is a strong level, and for many applications enough. Its gap is that the engine only compares write sets. The reads that justified your decision are never checked.

Write skew. Two doctors on call, rule: at least one stays.

-- both sessions: BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM oncall WHERE on_call;               -- 2 in both
UPDATE oncall SET on_call = false WHERE doctor = 'alice'; -- session 1
UPDATE oncall SET on_call = false WHERE doctor = 'bob';   -- session 2
COMMIT; COMMIT;                                           -- both succeed; nobody on call

The write sets are disjoint, so first-committer-wins sees nothing. Four ways out:

Phantoms. Now the rule is "no overlapping booking for room 3". Both transactions query for bookings in the 2 pm slot, find none, and insert one:

SELECT count(*) FROM bookings WHERE room = 3 AND slot = '14:00';  -- 0 in both
INSERT INTO bookings(room, slot, who) VALUES (3, '14:00', ...);   -- both

SELECT ... FOR UPDATE here finds zero rows and in PostgreSQL locks nothing, so it cannot help. Behaviour differs by engine: InnoDB at Repeatable Read locks the scanned index range with gap or next-key locks during a locking read, which blocks other sessions from inserting into it (a unique-index lookup of one exact value takes only a record lock). Gap locks do not conflict with each other, so two sessions can both pass the check and then each insert waits on the other's gap lock, which InnoDB resolves as a deadlock. CockroachDB's documentation states that SELECT FOR UPDATE and SELECT FOR SHARE do not prevent insertion of new rows matching the search condition. The portable fixes are a unique index or exclusion constraint on the thing that must not overlap (PostgreSQL has exclusion constraints for ranges), locking a parent row both transactions must update, or Serializable.

:::warn "Repeatable Read has no phantoms" True in PostgreSQL for repeated reads, but write skew through an insert is still possible. Lock-based Repeatable Read in SQL Server still allows phantoms. Say which engine. :::

Roads one and two: run serially, or lock everything

Before SSI, there were two ways to be serializable, and they are still in production.

Road one: actual serial execution. Run transactions one at a time on a single thread. No locks, no snapshots, no anomalies. It only works if a transaction never waits on the network, so the whole transaction is submitted as one stored procedure, and the data fits in memory or I/O is fast. VoltDB is built this way: each partition has its own single-threaded executor and a stored procedure is the transaction. Throughput scales by adding partitions. The limits are plain:

Road two: two-phase locking (2PL). Take a shared lock to read and an exclusive lock to write, and hold every lock until commit. Phantoms need locks on ranges or predicates, not only rows. SQL Server SERIALIZABLE uses key-range locks. InnoDB at SERIALIZABLE turns plain SELECTs into shared locking reads when autocommit is off, and uses next-key locks.

-- deadlock: each holds one lock and wants the other
T1: UPDATE accounts SET ... WHERE id = 1;    -- locks row 1
T2: UPDATE accounts SET ... WHERE id = 2;    -- locks row 2
T1: UPDATE accounts SET ... WHERE id = 2;    -- waits for T2
T2: UPDATE accounts SET ... WHERE id = 1;    -- waits for T1: cycle
-- the engine detects it and aborts one transaction; the app retries

2PL costs real throughput: readers block writers, writers block readers, and the longest transaction sets the wait. Deadlocks are normal, so every client needs retry logic even without Serializable.

RoadMechanismPays withFits
Serial executionOne executor per partitionSingle-partition, short, stored-procedure transactionsIn-memory, partitionable OLTP
2PLShared/exclusive and range locks to commitBlocking and deadlocksMixed workloads that want no anomaly aborts beyond deadlocks
SSI (next section)Snapshot plus conflict trackingAborts and retriesRead-heavy workloads with rare conflicts

Road three: serializable snapshot isolation

SSI (Cahill, Rohm and Fekete, SIGMOD 2008) runs transactions on snapshot isolation and detects, at run time, the patterns that can only produce a non-serializable result. PostgreSQL 9.1 (2011) was the first production database to ship it; Ports and Grittner describe the implementation.

Vocabulary. If T1 reads a version of an object and T2 writes a newer one, T1 appears to run before T2, because T1 did not see T2's write. That is an rw-antidependency. A predicate read counts: if T1 scans for rows matching a condition and T2 inserts a matching row, that is an rw-antidependency too. A serialization anomaly is a cycle of dependencies. The on-call case is a cycle of two: each doctor's read is invalidated by the other's write.

The dangerous structure. Every snapshot-isolation anomaly contains two adjacent rw-antidependencies, T1 to T2 to T3, where T3 commits first. PostgreSQL waits until T3 commits, then aborts T2 when it can, because the retried T2 is no longer concurrent with T3 and cannot form the same structure again. It aborts T1 only if T2 and T3 are both committed already.

How it tracks reads. PostgreSQL takes SIRead locks on what a transaction reads. They never block anyone, so they cannot cause deadlocks. They must outlive the transaction, until overlapping read-write transactions finish. If the lock table is short of memory, tuple locks are promoted to page and then relation locks. A sequential scan always takes a relation-level lock. Coarser locks mean more false positives: the engine aborts transactions that a perfect check would allow.

What it costs. The PostgreSQL authors measured under 7 percent against snapshot isolation on two benchmarks, and better throughput than 2PL on read-intensive loads. Their numbers, on their hardware, so treat them as direction, not a promise. Read-only transactions are cheap, and SERIALIZABLE READ ONLY DEFERRABLE waits for a safe snapshot, then reads with no SSI tracking. It is the only case where Serializable blocks and Repeatable Read does not.

What you must do:

:::warn Serializable still aborts SSI is optimistic. Under heavy contention on hot rows, the abort rate is the price. Saying "Serializable means no retries" is the classic wrong answer. :::

What your engine really does

Versions matter here. This table is what the documentation and public test results say for the versions named.

EngineDefaultNotable levelsThe catch
PostgreSQL 18Read Committed (snapshot per statement)Repeatable Read is snapshot isolation; Serializable is SSI; Read Uncommitted behaves as Read CommittedRR still allows write skew; before 9.1, SERIALIZABLE meant today's RR
MySQL 8.4 InnoDBRepeatable ReadPlain reads use the snapshot; locking reads, UPDATE and DELETE can see and change rows committed after it, and lock the scanned range with gap or next-key locksThe manual advises against mixing plain and locking reads in one Repeatable Read transaction (use Serializable); Jepsen found it is not snapshot isolation
Oracle 23Read Committed (statement-level)Only Read Committed, SERIALIZABLE and read-only; no Read UncommittedSERIALIZABLE is snapshot isolation (ORA-08177 on a conflicting row), so write skew is possible
SQL Server (current docs)Read CommittedSNAPSHOT is SI and needs ALLOW_SNAPSHOT_ISOLATION; SERIALIZABLE uses key-range locksDefault Read Committed uses locks on the box product; Azure SQL Database turns on read-committed snapshot by default; Repeatable Read is lock-based and still allows phantoms
CockroachDBSerializableRead Committed is enabled by default through a cluster setting (sql.txn.read_committed_isolation.enabled); the stable docs list these two levels, and Hermitage also lists a Repeatable Read (snapshot isolation) level, so check your versionUnder Read Committed, write skew and lost update are possible
SpannerSerializable with external consistencyOptional Repeatable Read, which is snapshot isolationUnder it write skew is possible; use FOR UPDATE

PostgreSQL anomalies by level (read-modify-write code, not atomic statements):

AnomalyRead CommittedRepeatable ReadSerializable
Dirty readnonono
Read skew, non-repeatable readyesnono
Phantom (repeated query)yesnono
Lost updateyesabortsaborts
Write skewyesyesaborts

An atomic UPDATE t SET n = n - 1 WHERE id = 1 AND n > 0 is safe even in Read Committed. PostgreSQL re-evaluates the WHERE clause against the new row version when the first updater commits. The same row is the only guarantee; multi-row statements can see odd results.

Check the engine, not the name. Kleppmann's Hermitage project runs the same anomaly tests across engines and publishes the matrix. It lists PostgreSQL Repeatable Read and Oracle SERIALIZABLE as snapshot isolation and SQL Server Repeatable Read as preventing lost update but not phantoms. It lists InnoDB Repeatable Read only as monotonic atomic view, not snapshot isolation (lost update and write skew are not prevented, and phantoms only in read-only contexts), and Jepsen later confirmed on 8.0.34 that it is not snapshot isolation (next section).

:::key The line to remember Oracle's Serializable and PostgreSQL's Repeatable Read are the same strength, snapshot isolation. Among the Serializable levels here, InnoDB's and SQL Server's rely on locking reads; PostgreSQL's is Serializable by dependency detection on top of snapshots, and CockroachDB's also resolves conflicts by aborting with 40001 rather than by locking reads. :::

Distributed databases, strictness, and what Jepsen found

Serializable is not the strongest word. Serializable says the result equals some serial order, not necessarily the real-time order. Client 1 commits x = 1, is told so, and then client 2 starts and reads x = 0. A serializable system may place client 2's transaction before client 1's. Strict serializability (Spanner calls it external consistency) forbids that: if one transaction finishes before another starts, the order respects it. Spanner provides it by default and documents it through TrueTime. Jepsen's 2016 test of a CockroachDB beta found it not strictly serializable (a causal-reverse anomaly even with clocks in bounds, stale reads in limited cases, and correctness that depended on clock offset staying under 250 ms), and it also found bugs that broke serializability itself, fixed in later betas. Do not quote that as the present state; it shows what to ask.

A level is a claim to test. Jepsen checks claims against real histories. Findings worth knowing:

Jepsen's MySQL run also confirmed that committed transactions survived crashes with innodb_flush_log_at_trx_commit=1.

Retries at the platform level. CockroachDB returns 40001 on conflicts and retries a statement or a batched transaction on its own only while the results fit a 16 KiB buffer; a multi-statement transaction over several round trips needs client retries. Spanner clients retry ABORTED, and Spanner uses wound-wait to avoid deadlock.

:::key What to say "I ask three things of any distributed database: which isolation level, whether it is strict, and what the failure-mode testing says for that version and topology." :::

Choosing a level, and how to say it in an interview

Start from the invariant, not the level. Then pick the narrowest tool that closes the race.

The retry loop is part of the design. Retry the whole transaction, not the failed statement. Cap attempts and add jittered backoff, so a hot row does not turn into a retry storm. Keep side effects (emails, payments, queue publishes) out of the transaction body: an email sent in attempt one is sent again in attempt two. Use an outbox row written in the same transaction, or an idempotency key.

A five-step answer you can say out loud:

  1. State the invariant: "at least one doctor stays on call".
  2. Name the anomaly: "under snapshot isolation that is write skew; both transactions read two and write different rows".
  3. Name the engine's behaviour: "on PostgreSQL, Repeatable Read allows it; Serializable aborts one".
  4. Choose the tool: "I would take FOR UPDATE on the roster rows, or run Serializable and retry on 40001".
  5. State the cost: "retries under contention, and I keep side effects out of the transaction".

Follow-ups to expect:

Common wrong statements

Wrong statementWhy it is wrongSay instead
"ACID means Serializable."Engines default to Read Committed or Repeatable Read"ACID gives atomicity and durability; isolation is a level I choose."
"MVCC means no locks."Writers lock rows; two writers of one row serialize"MVCC stops readers and writers blocking each other."
"Repeatable Read prevents phantoms."Engine-specific: yes in PostgreSQL, no for lock-based SQL Server, split in InnoDB"Depends on the engine; here is what I'd verify."
"Snapshot isolation is serializable."Write skew"SI misses write skew; I'd add a lock, constraint or Serializable."
"Oracle SERIALIZABLE is truly serializable."It is snapshot isolation"It is SI, so I still guard cross-row rules."
"Serializable means no retries."SSI and optimistic systems abort with 40001, and 2PL deadlocks"I'll wrap it in a retry loop."
"FOR UPDATE fixes phantoms."In PostgreSQL it cannot lock rows that do not exist yet; InnoDB gap locks cover a scanned range, but two sessions can still deadlock"A constraint, a parent-row lock or Serializable."
"Lost updates only happen at weak levels."Jepsen found them in MySQL 8.0.34 Repeatable Read on one healthy node"Prefer atomic statements; do not rely on the level name."
"MySQL's default is Read Committed."It is Repeatable Read; PostgreSQL, Oracle and SQL Server default to Read Committed"InnoDB defaults to Repeatable Read."

The last scene to picture: a reader sails past while writer 2 waits on writer 1's row lock. After writer 1 commits, writer 2 proceeds. In PostgreSQL Read Committed it re-checks its WHERE clause on the new version; in Repeatable Read it gets the serialization error instead.

How to say it in the interview

"I never stop at 'we'll use a transaction'. I say which invariant the code protects, which anomaly would break it on this engine at this level, and which single tool closes the gap: an atomic statement, a lock on the rows I read, a constraint, or Serializable with a retry loop."

Checklist

Practice this interactively
Share this lessonWhatsAppLinkedInX

Related lessons