Isolation & Concurrency Control: The Anomaly You Are Allowing Right Now
Isolation levels are not a safety dial — they are a formal specification of which data anomalies your application tolerates. This article maps every anomaly (dirty read, phantom read, write skew) to the isolation level that prevents it, and explains when optimistic concurrency is correct versus when pessimistic locking is the only safe choice.
Advanced Database & State Management
Isolation & Concurrency Control: The Anomaly You Are Allowing Right Now
A database is not a dumb storage box — it is a contract between your write path, your read path, and your consistency guarantees. Every design decision you defer becomes a production incident you eventually own. The most deferred decision in transaction design is not whether to use transactions — it is which isolation level to use, and therefore which data anomalies your application is silently tolerating right now. Most applications run on READ COMMITTED because it is the Postgres default, not because someone made a deliberate choice. This article forces that choice.
This is Part 2a of the Advanced Database & State Management series. It builds directly on the WAL and buffer pool mechanics from Part 1 — Database Internals. If you have not read Part 1, the lock and WAL concepts here will be harder to follow.
1. The Anomaly Taxonomy: What Can Actually Go Wrong
Isolation levels are defined by which anomalies they prevent. Engineers who cannot name the anomalies cannot reason about the level. Here is the complete set, with concrete examples — not definitions.
1.1 Dirty Read
A transaction reads a row that another transaction has written but not yet committed.
Why it matters: The read value never existed as a committed fact. If Session A rolls back, Session B made a decision based on data that was never real.
1.2 Non-Repeatable Read
A transaction reads the same row twice and gets different values because another transaction committed a change between the two reads.
Why it matters: Any multi-step business logic that reads a row, computes something, then reads it again to validate — e.g., check balance, compute fee, re-read balance to confirm — is broken under non-repeatable reads.
1.3 Phantom Read
A transaction executes the same range query twice and gets a different set of rows because another transaction inserted or deleted matching rows between the two reads.
Why it matters: Any business rule enforced by "count matching rows, then decide" — e.g., "only allow booking if fewer than 10 seats are pending" — is broken. The count was correct at read time and stale by decision time.
1.4 Lost Update
Two transactions read the same row, compute a new value, and write back — the second write overwrites the first without knowing about it.
1.5 Write Skew
Two transactions each read a set of rows, make a decision based on what they see, then write to different rows — but together their writes violate a constraint that neither transaction could detect alone.
Write skew is the most dangerous anomaly because both transactions behaved correctly in isolation. The violation is invisible until after both commit.
2. Isolation Level Mapping: The Decision Table
Each isolation level prevents a specific set of anomalies. This table is the core reference:
| Anomaly | READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE |
|---|---|---|---|---|
| Dirty Read | ✗ Allowed | ✅ Prevented | ✅ Prevented | ✅ Prevented |
| Non-Repeatable Read | ✗ Allowed | ✗ Allowed | ✅ Prevented | ✅ Prevented |
| Phantom Read | ✗ Allowed | ✗ Allowed | ✅ Prevented¹ | ✅ Prevented |
| Lost Update | ✗ Allowed | ✗ Allowed | ✅ Prevented¹ | ✅ Prevented |
| Write Skew | ✗ Allowed | ✗ Allowed | ✗ Allowed | ✅ Prevented |
¹ In Postgres,
REPEATABLE READprevents phantom reads and lost updates via snapshot isolation — stronger than the SQL standard requires. MySQL'sREPEATABLE READdoes not prevent phantoms without explicit gap locks.

Postgres default is READ COMMITTED — it prevents dirty reads only. If your application reads the same row twice in one transaction and the result must be consistent, you need at minimum REPEATABLE READ. If your business rule spans multiple rows (any "if X then allow Y" pattern), you need SERIALIZABLE or an explicit lock.
3. Pessimistic Concurrency: Locks
Pessimistic concurrency assumes conflict will happen and blocks it preemptively by acquiring a lock before reading.
3.1 SELECT FOR UPDATE
SELECT FOR UPDATE acquires an exclusive row-level lock. Any other transaction that tries SELECT FOR UPDATE or UPDATE on the same row will block until the lock is released.
3.2 SELECT FOR SHARE
3.3 Deadlock Anatomy and Structural Prevention
A deadlock occurs when two transactions each hold a lock the other needs:
Deadlocks are not random — they are caused by inconsistent lock acquisition order. The fix is never to increase deadlock_timeout. It is to redesign the code so all concurrent paths acquire locks in the same deterministic order. Once the order is consistent, deadlocks are structurally impossible.
4. Optimistic Concurrency: Version Columns and CAS
Optimistic concurrency assumes conflict is rare. Instead of locking before reading, it reads freely, computes the new value, then performs a compare-and-swap (CAS) on write — checking that the row has not changed since it was read.
4.1 The Version Column Pattern
The retry obligation is yours. When rowsAffected = 0, the ORM version column alone does not retry the business logic — your application code must re-read the row, re-execute the computation, and re-attempt the write. ORMs that throw an OptimisticLockException are telling you to implement this retry. Most teams catch the exception and surface a generic error to the user instead, which is wrong.
4.2 When to Use Each Strategy
| Condition | Use Pessimistic | Use Optimistic |
|---|---|---|
| Conflict probability | High (many writers, same rows) | Low (writes rarely collide) |
| Retry cost | Prohibitive (long computations, side effects) | Cheap (fast re-read and re-compute) |
| Lock hold duration | Short | Any duration |
| Distributed system | May not have cross-node locks | Works naturally (no coordination) |
| ORM default | Rarely | Always (version columns) |

Most ORMs (Prisma, Hibernate, TypeORM) default to optimistic locking via version columns. This means the ORM version column does not make the pessimistic-vs-optimistic decision for you — it already made it. If your workload has high write collision probability, you need to add explicit SELECT FOR UPDATE on top of the ORM, not rely on the version column.
5. Snapshot Isolation vs Serializable Snapshot Isolation: The Cross-Database Trap
The word SERIALIZABLE means different things in different databases.
5.1 Postgres: Serializable Snapshot Isolation (SSI)
Postgres SERIALIZABLE uses SSI — it tracks read/write dependencies between concurrent transactions and aborts transactions that would create a serialization cycle. It does not use two-phase locking.
5.2 MySQL: Two-Phase Locking (2PL)
MySQL SERIALIZABLE uses two-phase locking — every SELECT implicitly becomes SELECT FOR SHARE, acquiring shared locks on all rows read. This prevents phantom reads and write skew by blocking, not aborting.
5.3 CockroachDB: SSI with Different Semantics
CockroachDB's SERIALIZABLE also uses SSI — but its conflict detection is distributed across Raft nodes, meaning retry rates under contention are higher than in single-node Postgres. The same keyword, three fundamentally different implementations.
Never copy isolation level configuration or retry logic between Postgres, MySQL, and CockroachDB without verifying the implementation model. The SQL keyword is the same. The behavior is not.
Summary
| Concept | Rule |
|---|---|
| READ COMMITTED | Postgres default — prevents dirty reads only; allows non-repeatable reads and phantoms |
| REPEATABLE READ | Prevents non-repeatable reads and phantoms (Postgres); does NOT prevent write skew |
| SERIALIZABLE | Prevents all anomalies including write skew — Postgres uses SSI (abort on conflict), MySQL uses 2PL (block on conflict) |
| Dirty read | Read an uncommitted value — only possible at READ UNCOMMITTED (MySQL only in practice) |
| Write skew | The invisible anomaly — two correct-in-isolation transactions that together violate a constraint; requires SERIALIZABLE |
| SELECT FOR UPDATE | Pessimistic lock — blocks concurrent writers until commit; correct when conflict probability is high |
| Optimistic + version column | CAS pattern — correct when conflict is rare; application owns the retry obligation on conflict |
| Deadlock | Not random — caused by inconsistent lock order; fix by enforcing consistent acquisition order |
| SERIALIZABLE across DBs | Same keyword, three different implementations — do not assume behavior transfers across Postgres/MySQL/CockroachDB |
What's Next
In Part 2b, we extend the transaction foundation to the distributed layer: consistency models across replicas and services — what linearizability, causal consistency, and eventual consistency mean for the read paths you design in your ORM, replica config, and cache layer. → Consistency Models & Distributed Coordination
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.