8.3 Weak Isolation Levels
Isolation levels
- 4/6
- prevented
- 2
- left to you
| Dirty read | Dirty write | Read skew | Lost update | Write skew | Phantom | |
|---|---|---|---|---|---|---|
| Read uncommitted | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ |
| Read committed | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ |
| Snapshot isolation | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ |
| Serializable | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
✓ prevented · ✗ possible
Snapshot isolation still permits: Write skew, Phantom. Write skew is the trap here — snapshot isolation is strong enough that developers stop thinking about concurrency, and write skew is invisible until two transactions read an overlapping set and each invalidates the other’s premise.
Concurrency bugs are hard to find by testing, because they are triggered only when you get unlucky with the timing. They occur rarely and are usually difficult to reproduce. Concurrency is also difficult to reason about, especially in a large application where you don't necessarily know which other pieces of code are accessing the database.
And they are not theoretical:
Concurrency bugs caused by weak isolation have caused substantial loss of money, INCLUDING BANKRUPTING A BITCOIN EXCHANGE, led to investigation by financial auditors, and caused customer data to be corrupted.
A popular comment is "Use an ACID database if you're handling financial data!" — but that MISSES THE POINT. Even many popular relational database systems (usually considered ACID) USE WEAK ISOLATION, so they wouldn't necessarily have prevented these bugs.
And the security framing, which people forget:
Even if concurrency issues are rare in normal operation, you have to consider that AN ATTACKER MIGHT DELIBERATELY SEND A BURST OF HIGHLY CONCURRENT REQUESTS TO YOUR API in an attempt to exploit concurrency bugs. To build applications that are reliable and secure, such bugs must be systematically prevented.
(A great aside: much of the banking system relies on text files exchanged via secure FTP. In this context, having an AUDIT TRAIL and human-level fraud prevention is actually more important than ACID properties.)
3.1 Read Committed
Two guarantees:
- No dirty reads — you read only committed data
- No dirty writes — you overwrite only committed data
It is the DEFAULT in Oracle, PostgreSQL, SQL Server, and many others.
No dirty reads
“Any writes by a transaction become visible to others only when that transaction commits — and then all its writes become visible at once.”
Why preventing dirty reads matters:
- A transaction updating several rows → a dirty read means another transaction sees SOME updates but not others (the email/counter case). Seeing a partially updated state is confusing to users and may cause other transactions to make INCORRECT DECISIONS.
- If a transaction aborts, its writes are rolled back. With dirty reads, a transaction may see data that is LATER ROLLED BACK — data that was never actually committed. Any transaction that read uncommitted data would ALSO need to be aborted → CASCADING ABORTS.
No dirty writes
A dirty write = a later write overwrites a value written by a transaction that hasn't committed yet. Prevented usually by DELAYING the second write until the first transaction commits or aborts.
A used car sale needs two writes: update the listing, and send the invoice. Two buyers try to buy the same car at the same time.
Bryce’s write wins the listing and Aaliyah’s write wins the invoice: the sale is awarded to Bryce, but the invoice is sent to Aaliyah. Read-committed isolation prevents such mishaps.
⚠️ BUT read-committed does NOT prevent the lost-update race of §1.3. There, the second write happens AFTER the first transaction committed — so it's not a dirty write. It's still incorrect, but for a different reason.
Implementing read committed
Dirty writes → row-level locks. A transaction wanting to modify a row must acquire a lock and hold it until commit or abort. Only one transaction holds it at a time. Done automatically in read-committed mode and stronger.
Dirty reads — two options:
| Option | Problem |
|---|---|
| Read locks (briefly acquire and release the same lock on read) | Does not work well in practice: ONE LONG-RUNNING WRITE TRANSACTION CAN FORCE MANY OTHER TRANSACTIONS TO WAIT — even transactions that only read. Harms read-only response times and is bad for operability: a slowdown in one part of an application has a knock-on effect in a completely different part. (Still used by IBM Db2 and SQL Server with read_committed_snapshot=off) |
| Remember both values ✔ | For every row written, the database remembers BOTH the old committed value AND the new value set by the transaction holding the write lock. Readers are simply given the OLD value while the transaction is ongoing |
Read uncommitted is even weaker: prevents dirty writes but NOT dirty reads. Better performance (no need to store two versions) and reduces the probability of — but does not prevent — lost updates.
3.2 Snapshot Isolation and Repeatable Read
The anomaly read-committed still permits — READ SKEW:
Aaliyah has $1,000: account 1 = $500, account 2 = $500. A transaction transfers $100 from account 2 to account 1.
Aaliyah sees $900. “It seems that $100 has vanished into thin air.”
This is read skew, an example of a nonrepeatable read: reading account 1 again at the end would give $600, a different value from the first query. Read skew is considered acceptable under read committed — both balances Aaliyah saw were indeed committed at the time she read them.
(Terminology note: "skew" is overloaded — in Ch 7 it meant an unbalanced workload with hot spots; here it means a TIMING ANOMALY.)
Where temporary inconsistency is NOT tolerable:
| Case | Why |
|---|---|
| Backups | Copying the entire database may take hours, during which writes continue. Some parts of the backup contain older data and other parts newer. If you restore from such a backup, the inconsistencies (such as disappearing money) BECOME PERMANENT |
| Analytical queries and integrity checks | Queries scanning large parts of the database return nonsensical results if they observe parts of the database at different points in time |
Snapshot isolation: EACH TRANSACTION READS FROM A CONSISTENT SNAPSHOT — it sees all the data committed in the database at the start of that transaction. Even if the data is subsequently changed, each transaction sees only the old data from that particular point in time.
A boon for long-running read-only queries. It is very hard to reason about the meaning of a query if the data it operates on is changing at the same time.
Supported by PostgreSQL, MySQL/InnoDB, Oracle, SQL Server. Some databases — Oracle, TiDB, Aurora DSQL — choose snapshot isolation as their HIGHEST isolation level. Cloud warehouses like BigQuery frequently use it, since it provides a point-in-time view for analytical queries.
Multiversion concurrency control (MVCC)
The key performance principle of snapshot isolation: READERS NEVER BLOCK WRITERS, AND WRITERS NEVER BLOCK READERS. This lets a database handle long-running read queries on a consistent snapshot at the same time as processing writes normally, without any lock contention between the two. (Writes still take write locks against each other, to prevent dirty writes.)
Instead of two versions per row, the database keeps SEVERAL COMMITTED VERSIONS, because various in-progress transactions may need to see different points in time.
PostgreSQL's implementation:
Every transaction gets a unique, always-increasing transaction ID (txid), and every write is tagged with the writer’s txid. All versions live in the same heap, committed or not:
| account | balance | inserted_by | deleted_by | |
|---|---|---|---|---|
| 2 | $500 | 3 | 13 | old version, marked deleted |
| 2 | $400 | 13 | — | new version |
An update is internally translated into a delete and an insert. Deletion doesn’t remove the row — it sets deleted_by. Later, a garbage collection process removes rows marked for deletion, once no transaction can access them, and frees their space. Versions of a row form a linked list so queries can iterate over them.
(Precision note: PostgreSQL txids are 32-bit, so they overflow after ~4 billion transactions. The VACUUM process performs cleanup to ensure overflow doesn't affect the data. This is the transaction-ID-wraparound hazard.)
Visibility rules
A row is visible if BOTH of these are true:
- At the time the reader's transaction started, the transaction that INSERTED the row had already committed
- The row is not marked for deletion, or if it is, the transaction that requested deletion HAD NOT YET COMMITTED at the time the reader's transaction started
Mechanically, four rules:
- At transaction start, the database lists all OTHER transactions in progress at that time. Any writes those transactions make are IGNORED, EVEN IF THEY SUBSEQUENTLY COMMIT — this is what makes the snapshot unaffected by another transaction committing
- Writes by transactions with a LATER txid are ignored, regardless of whether they committed
- Writes by ABORTED transactions are ignored, regardless of when the abort happened — which conveniently means on abort we don't need to immediately remove rows; the visibility rule filters them out and GC removes them later
- All other writes are visible
By never updating values in place but instead inserting a new version every time, the database can provide a consistent snapshot while incurring only a SMALL overhead. A long-running transaction may keep reading values that, from other transactions' points of view, have long been overwritten or deleted.
Indexes and MVCC
- Most common: each index entry points at one version of a row (oldest or newest); each version references the next-oldest/newest. A query using the index must ITERATE over the rows to find one that is visible AND matches. When GC removes invisible versions, the corresponding index entries can also be removed.
- PostgreSQL optimization: avoid index updates if different versions of the same row fit on the same page (HOT updates).
- Some databases store only DIFFERENCES between versions, to save space.
- CouchDB, Datomic, LMDB use an IMMUTABLE (copy-on-write) B-tree: don't overwrite pages, create a new copy of each modified page; parent pages up to the root are copied and updated to point to the new children. Pages unaffected by a write are SHARED with the new tree.
With immutable B-trees, every write transaction creates a NEW B-TREE ROOT, and a particular root IS a consistent snapshot of the database at the point it was created. There is NO NEED to filter rows by transaction ID, because subsequent writes cannot modify an existing B-tree — they can only create new roots. (Requires a background compaction/GC process.)
The naming disaster
The same thing, different names:
| Database | What it calls the level | What the level actually is |
|---|---|---|
| PostgreSQL | “repeatable read” | snapshot isolation |
| Oracle | “serializable” | snapshot isolation |
The same name, different things:
| Database | What it calls the level | What the level actually is |
|---|---|---|
| PostgreSQL | “repeatable read” | snapshot isolation |
| MySQL | “repeatable read” | MVCC with Weaker consistency than snapshot isolation |
| IBM Db2 | “repeatable read” | Serializability |
Why: the SQL standard has no concept of snapshot isolation, because the standard is based on System R's 1975 definitions and snapshot isolation hadn't been invented. It defines repeatable read, which looks superficially similar. PostgreSQL calls its level "repeatable read" because it meets the standard's requirements and so it can claim standards compliance.
The SQL standard's definition of isolation levels is FLAWED — ambiguous, imprecise, and not as implementation-independent as a standard should be. Even though several databases implement "repeatable read," there are big differences in the guarantees they provide. As a result, NOBODY REALLY KNOWS WHAT REPEATABLE READ ISOLATION MEANS.
3.3 Preventing Lost Updates
The lost update problem: an application READS a value, MODIFIES it, and WRITES BACK the modified value. If two transactions do this concurrently, one modification can be LOST, because the second write doesn't include the first modification — the later write CLOBBERS the earlier write.
Where it shows up:
- Incrementing a counter or updating an account balance
- Making a local change to a complex value — adding an element to a list within a JSON document (parse, change, write back)
- Two users editing a wiki page, each saving by sending the ENTIRE page contents to the server, overwriting whatever is currently there
Five solutions:
| # | Solution | Detail |
|---|---|---|
| 1 | Atomic write operations | UPDATE counters SET value = value + 1 WHERE key = 'foo'; — usually the BEST solution if your code can be expressed in terms of them. MongoDB has atomic ops for local JSON modifications; Redis for priority queues. Implemented by exclusively locking the object when read, or by forcing all atomic operations onto a single thread. ⚠️ ORM frameworks make it easy to accidentally write UNSAFE read-modify-write cycles instead — a source of subtle bugs difficult to find by testing |
| 2 | Explicit locking (SELECT … FOR UPDATE) | The application locks objects it will update; concurrent updaters must wait. Needed when correctness requires logic you can't express as a database query — e.g. a multiplayer game where a move must abide by the rules. ⚠️ You must carefully think about application logic; it's EASY TO FORGET A LOCK somewhere and introduce a race condition. ⚠️ Locking multiple objects risks DEADLOCK — databases usually detect and abort one; you retry at the application level |
| 3 | Automatic lost-update detection | Let them run in parallel; if the transaction manager DETECTS a lost update, abort and force a retry. Efficient in conjunction with snapshot isolation. PostgreSQL's repeatable read, Oracle's serializable, and SQL Server's snapshot isolation DETECT this automatically. MySQL/InnoDB's repeatable read DOES NOT. Some authors argue a database must prevent lost updates to qualify as snapshot isolation — under that definition MySQL doesn't provide it. Big advantage: it doesn't require application code to use special features, so it's LESS ERROR-PRONE |
| 4 | Conditional writes (compare-and-set) | For databases without transactions. UPDATE wiki_pages SET content = 'new' WHERE id = 1234 AND content = 'old' — no effect if changed; check whether the update took effect and retry. Better: a VERSION NUMBER column incremented on every update — optimistic locking |
| 5 | Conflict resolution / replication | See below |
⚠️ A subtle MVCC interaction with conditional writes: if another transaction concurrently modified
content, the new content may not be visible under the MVCC visibility rules. Many MVCC implementations have an EXCEPTION: values written by other transactions ARE visible to the evaluation of theWHEREclause ofUPDATEandDELETEqueries, even though those writes are not otherwise visible in the snapshot.
Replication changes everything:
Locks and conditional writes ASSUME THERE IS A SINGLE UP-TO-DATE COPY OF THE DATA. Multi-leader and leaderless databases usually allow several writes concurrently and replicate asynchronously, so they CANNOT GUARANTEE a single up-to-date copy. Thus techniques based on locks or conditional writes DO NOT APPLY.
Instead: allow concurrent writes to create SIBLINGS and merge them afterward. Merging prevents lost updates if the updates are COMMUTATIVE — incrementing a counter, adding to a set. That's the idea behind CRDTs.
However, some operations (such as conditional writes) CANNOT be made commutative. And LWW — the default in many replicated databases — IS PRONE TO LOST UPDATES.
3.4 Write Skew and Phantoms
The on-call doctors example:
The rule is that at least one doctor must be on call. Aaliyah and Bryce are both on call, both feel unwell, and both click “go off call” at approximately the same time.
Now no doctor is on call and the invariant is violated. Both reads came from a consistent snapshot, so snapshot isolation permits this.
WRITE SKEW: neither a dirty write nor a lost update, because the two transactions are UPDATING TWO DIFFERENT OBJECTS. It's definitely a race condition: if they had run one after another, the second doctor would have been prevented from going off call.
Write skew is a GENERALIZATION of the lost-update problem: it can occur if two transactions READ THE SAME OBJECTS and then UPDATE SOME OF THOSE OBJECTS (different transactions may update different objects). In the special case where they update the SAME object, you get a dirty write or lost update instead.
Your options are far more restricted than for lost updates:
| Option | Verdict |
|---|---|
| Atomic single-object operations | ✗ Don't help — multiple objects are involved |
| Automatic lost-update detection | ✗ Doesn't help — write skew is NOT automatically detected in PostgreSQL's repeatable read, MySQL/InnoDB's repeatable read, Oracle's serializable, or SQL Server's snapshot isolation |
| Database constraints | ✗ Mostly — you'd need a constraint involving MULTIPLE OBJECTS. Most databases don't have built-in support, though triggers or materialized views may work |
Explicit SELECT … FOR UPDATE on the rows the transaction depends on | ✔ The second-best option if you can't use serializable |
| True serializable isolation | ✔ The only automatic prevention |
Four more examples of write skew
| Example | The check | Why it fails |
|---|---|---|
| Meeting room booking | SELECT COUNT(*) FROM bookings WHERE room_id=123 AND end_time > … AND start_time < … then INSERT | Snapshot isolation does not prevent another user from concurrently inserting a conflicting meeting |
| Multiplayer game | A lock prevents two players moving the SAME figure | The lock doesn't prevent players moving TWO DIFFERENT figures to the SAME POSITION, or other rule-violating moves. Sometimes a uniqueness constraint helps; otherwise you're vulnerable |
| Claiming a username | Check whether the name is taken, then create the account | Not safe under SI — but a UNIQUENESS CONSTRAINT is a simple solution here (the second transaction aborts) |
| Preventing double-spending | Insert a tentative spending item, list all items, check the sum is positive | Two spending items inserted concurrently can together make the balance negative, with neither transaction noticing the other |
Phantoms — the underlying mechanism
The universal pattern:
- A
SELECTchecks whether a requirement is satisfied by searching for rows matching a condition. - Application code decides how to continue based on the result.
- If it goes ahead, it writes (
INSERT/UPDATE/DELETE) and commits.
The write in step 3 changes the precondition of the decision in step 2. Repeating the SELECT after committing gives a different result. The steps may also occur in a different order — write first, then SELECT, then decide.
In the doctors example, the row modified in ③ was ONE OF THE ROWS RETURNED IN ①, so
SELECT FOR UPDATEcan lock it and make the transaction safe.The other four examples are DIFFERENT: they check for the ABSENCE of rows matching a condition, and the write ADDS a row matching that same condition. IF THE QUERY IN ① DOESN'T RETURN ANY ROWS,
SELECT FOR UPDATECAN'T ATTACH LOCKS TO ANYTHING.A PHANTOM is a write in one transaction that changes the result of a search query in another transaction. Snapshot isolation avoids phantoms in READ-ONLY queries, but in read/write transactions phantoms lead to particularly tricky cases of write skew. (SQL generated by ORMs is also prone to write skew.)
Materializing conflicts
If there's no object to lock, artificially introduce one.
For the meeting rooms: create a table of time slots and rooms, one row per room per 15-minute period, for all combinations ahead of time (e.g. the next six months). A transaction locks (
SELECT FOR UPDATE) the rows for the desired room and period, then checks and inserts.The additional table isn't used to store booking information — it's PURELY A COLLECTION OF LOCKS.
⚠️ It can be hard and error-prone to figure out how to materialize conflicts, and IT'S UGLY TO LET A CONCURRENCY CONTROL MECHANISM LEAK INTO THE APPLICATION DATA MODEL. Materializing conflicts should be considered A LAST RESORT. A serializable isolation level is preferable in most cases.