Siddhant Deval
Siddhant DevalAuthor
Senior Full-Stack Engineer·Sep 13, 2026·16 min read

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.

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.

Architectural Note

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.

SQL
-- Session A (not yet committed)
BEGIN;
UPDATE accounts SET balance = 0 WHERE id = 42;
-- balance is now 0 in Session A's memory, but not committed

-- ❌ Session B (READ UNCOMMITTED — Postgres does not actually implement this,
--    but MySQL does) reads Session A's uncommitted write
SELECT balance FROM accounts WHERE id = 42;
-- Returns: 0 — a value that may be rolled back in the next millisecond

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.

SQL
-- Session A
BEGIN;
SELECT balance FROM accounts WHERE id = 42;  -- Returns: 1000

  -- Session B (commits between A's two reads)
  BEGIN;
  UPDATE accounts SET balance = 500 WHERE id = 42;
  COMMIT;

SELECT balance FROM accounts WHERE id = 42;  -- Returns: 500 ← different!
-- Session A saw two different values for the same row within one transaction
COMMIT;

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.

SQL
-- Session A
BEGIN;
SELECT COUNT(*) FROM orders WHERE status = 'pending';  -- Returns: 5

  -- Session B inserts a new pending order and commits
  INSERT INTO orders (status) VALUES ('pending');
  COMMIT;

SELECT COUNT(*) FROM orders WHERE status = 'pending';  -- Returns: 6 ← phantom!
COMMIT;

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.

SQL
-- ❌ Classic lost update: both sessions read balance = 1000
-- Session A: balance = balance - 100  → writes 900
-- Session B: balance = balance - 200  → writes 800 (overwrites A's 900)
-- Net result: 800. Correct result: 700. A's update is silently lost.

-- Session A                         | Session B
BEGIN;                               | BEGIN;
SELECT balance FROM accounts         | SELECT balance FROM accounts
  WHERE id = 42;  -- 1000           |   WHERE id = 42;  -- 1000
UPDATE accounts SET balance = 900   |
  WHERE id = 42;                     |
COMMIT;                              | UPDATE accounts SET balance = 800
                                     |   WHERE id = 42;
                                     | COMMIT;  -- A's change is gone

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.

SQL
-- Business rule: at least one doctor must always be on-call.
-- Two doctors both request time-off simultaneously.

-- Session A (Dr. Smith's request)         | Session B (Dr. Jones's request)
BEGIN;                                     | BEGIN;
SELECT COUNT(*) FROM doctors               | SELECT COUNT(*) FROM doctors
  WHERE on_call = true;  -- 2             |   WHERE on_call = true;  -- 2
-- "2 on call, safe to take one off"      | -- "2 on call, safe to take one off"
UPDATE doctors SET on_call = false         |
  WHERE id = 'smith';                      |
COMMIT;                                    | UPDATE doctors SET on_call = false
                                           |   WHERE id = 'jones';
                                           | COMMIT;
-- Result: 0 doctors on call. Rule violated. Neither transaction saw the other's write.

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 READ prevents phantom reads and lost updates via snapshot isolation — stronger than the SQL standard requires. MySQL's REPEATABLE READ does not prevent phantoms without explicit gap locks.

Anomaly × Isolation Level decision matrix showing which anomalies each isolation level prevents and allows
Anomaly × Isolation Level decision matrix showing which anomalies each isolation level prevents and allows
SQL
-- Set isolation level per transaction (not globally)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- ... your multi-read business logic ...
COMMIT;

-- Or set the default for a session
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE;
Crucial Requirement

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

SQL
-- ✅ Prevents lost update: lock the row before reading it,
-- so any concurrent transaction trying to read-for-update on the same row blocks
-- until this transaction commits or rolls back.
BEGIN;
SELECT balance FROM accounts
  WHERE id = 42
  FOR UPDATE;  -- Acquires exclusive row lock
-- No other transaction can modify or lock this row until COMMIT

UPDATE accounts SET balance = balance - 100 WHERE id = 42;
COMMIT;

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

SQL
-- Use FOR SHARE when you need to read a row and prevent it being deleted/updated,
-- but you are willing to allow other readers to also hold a share lock.
BEGIN;
SELECT * FROM products WHERE id = 99 FOR SHARE;
-- Other sessions can SELECT FOR SHARE — they cannot UPDATE or DELETE
COMMIT;

3.3 Deadlock Anatomy and Structural Prevention

A deadlock occurs when two transactions each hold a lock the other needs:

SQL
-- ❌ Deadlock: inconsistent lock acquisition order
-- Session A locks account 1, then tries to lock account 2
-- Session B locks account 2, then tries to lock account 1

-- Session A                          | Session B
BEGIN;                                | BEGIN;
SELECT * FROM accounts                | SELECT * FROM accounts
  WHERE id = 1 FOR UPDATE;           |   WHERE id = 2 FOR UPDATE;
-- ... processing ...                 | -- ... processing ...
SELECT * FROM accounts                | SELECT * FROM accounts
  WHERE id = 2 FOR UPDATE;  -- WAITS |   WHERE id = 1 FOR UPDATE;  -- WAITS
-- DEADLOCK DETECTED → one tx aborted
SQL
-- ✅ Structural prevention: always lock rows in the same order (ascending id)
-- Both sessions lock account 1 first, then account 2.
-- One will wait for the other — no cycle, no deadlock.
BEGIN;
SELECT * FROM accounts
  WHERE id IN (1, 2)
  ORDER BY id  -- Enforces consistent acquisition order
  FOR UPDATE;
COMMIT;
Pro Tip & Optimization

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

SQL
-- Schema: add a version column to any row that needs optimistic concurrency
ALTER TABLE orders ADD COLUMN version INTEGER NOT NULL DEFAULT 0;

-- ❌ Naive update: no conflict detection — overwrites concurrent changes silently
UPDATE orders SET status = 'shipped' WHERE id = 42;

-- ✅ Optimistic update: include version in WHERE clause
-- If the row was modified by someone else since our read, version won't match
-- and rowsAffected = 0 — the application must detect this and retry
UPDATE orders
  SET status = 'shipped', version = version + 1
  WHERE id = 42
    AND version = 3;  -- Must match the version we read

-- Application checks rows affected:
-- rowsAffected = 1 → success
-- rowsAffected = 0 → conflict: re-read, re-compute, retry
Performance / Safety Warning

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)
Pessimistic lock sequence vs Optimistic CAS attempt with retry obligation callout
Pessimistic lock sequence vs Optimistic CAS attempt with retry obligation callout
Crucial Requirement

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.

SQL
-- Postgres SERIALIZABLE: SSI detects the write-skew cycle and aborts one transaction
-- Both doctor transactions (from §1.5) would be caught:
-- Postgres detects that A read doctors, B read doctors, A wrote, B wrote
-- → serialization cycle → one transaction aborted with:
-- ERROR: could not serialize access due to read/write dependencies among transactions

BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT COUNT(*) FROM doctors WHERE on_call = true;
UPDATE doctors SET on_call = false WHERE id = 'smith';
COMMIT;  -- May throw serialization error → application must retry

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.

SQL
-- MySQL SERIALIZABLE: implicitly locks all read rows
-- The second doctor transaction blocks at the SELECT until the first commits
BEGIN;
SELECT COUNT(*) FROM doctors WHERE on_call = true;  -- Acquires shared locks
-- Concurrent sessions wanting to modify any on_call row are blocked here
COMMIT;

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.

SQL
-- ❌ Copying Postgres SERIALIZABLE advice to MySQL:
-- "Use SERIALIZABLE, it will abort conflicting transactions"
-- In MySQL, it blocks — it does not abort. Throughput characteristics are completely different.

-- ❌ Copying MySQL SERIALIZABLE tuning (lock timeouts) to CockroachDB:
-- CockroachDB uses abort-and-retry, not blocking — lock timeout tuning is irrelevant.
Performance / Safety Warning

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

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Transactions#Isolation Levels#Concurrency#PostgreSQL
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.