Module 04 · Lesson 03

Transactions, isolation, and the anomalies in between

ACID is a promise with a dial. Know which dial position your workload needs.

Advanced 9 min read Updated 2026-11-01

What you will be able to do

  • Map isolation levels to the anomalies they allow
  • Recognise a lost update and a write skew in a schema
  • Choose optimistic or pessimistic concurrency control with a reason
  • Explain ACID versus BASE without slogans

The anomalies, named

AnomalyWhat happensAllowed at
Dirty readYou read a value another transaction has not committed — and may roll backRead uncommitted
Non-repeatable readThe same row returns a different value twice in one transactionRead committed
Phantom readA re-run query returns new rows because someone inserted matching onesRepeatable read (in some engines)
Lost updateTwo transactions read, both write, one overwrites the otherRead committed
Write skewTwo transactions read overlapping data and write disjoint rows, breaking a shared invariantRepeatable read (even snapshot)

Write skew is the one that surprises people. A doctor on-call rule says at least one doctor must remain scheduled. Two doctors each see two on call, each takes themselves off, each write is valid in isolation — and now nobody is on call. Snapshot isolation cannot see the other's uncommitted write, so it permits both.

-- Both transactions run this, both commit, invariant broken
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM on_call WHERE shift = 'night';  -- 2
DELETE FROM on_call WHERE doctor_id = 7 AND shift = 'night';
COMMIT;
Serialisable or design around it
Either run the shift rule at serialisable isolation (the database detects the conflict and aborts one transaction), or move the invariant into a row both transactions must update — a shift row with a counter and a constraint that it never reaches zero. The second is faster and more explicit.

Levels and their price

LevelBlocks / abortsUse when
Read uncommittedNothingAnalytics that can tolerate garbage; almost never for writes
Read committedWrites block writes to the same rowDefault for most application traffic
Repeatable read (snapshot)Concurrent writers to the same row; reads never blockReports and multi-statement reads that must be consistent
SerialisableConflicting transactions abort and retryMoney, inventory, booking — anything with an invariant across rows

The cost of serialisable is retries under contention. At low conflict rates, retrying a handful of aborted transactions is cheaper than coordinating locks. At high contention, the abort rate explodes and you need to reduce the conflict surface: split the hot row, queue the writes, or accept a single-writer path for that aggregate.

Two ways to control concurrency

Optimistic
Read a version number, compute, then update only if the version is unchanged. If not, retry. Ideal when conflicts are rare: no locks held, no blocking.
Pessimistic
Take a row lock before reading, so nobody else can change it. Predictable under contention, at the cost of waiting and deadlock risk.
Conditional update
The cheapest form of both: UPDATE seats SET taken = true WHERE id = 5 AND taken = false and check the affected row count. The database does the check atomically.
Single-writer
Route all writes for an aggregate through one queue or partition owner. Serialisation becomes structural instead of transactional.
Seat booking, in three sentences
"The reservation is a conditional update on the seat row, so two people cannot both succeed. If it succeeds, I write a hold with a TTL so an abandoned checkout frees the seat. Payment confirms it and the hold becomes permanent; if payment fails, the hold expires on its own."

ACID versus BASE, without the slogan

ACID describes a single database's guarantees across a transaction: atomic, consistent, isolated, durable. BASE describes a system that accepts temporary inconsistency — reads may be stale, writes may conflict, and reconciliation happens later. Neither is better in the abstract. The question is whether the business can tolerate a window where two nodes disagree, and how long that window is. A like counter can be eventually consistent forever; an account balance cannot.

  • Cross-service transactions are usually sagas: a sequence of local transactions with compensating actions.
  • Distributed transactions (two-phase commit) give atomicity at the cost of coordination latency and blocking on coordinator failure.
  • Idempotent handlers make retries safe, which is what most saga steps need.
  • Outbox tables turn a database write and a message publish into one atomic local transaction.

Worth remembering

  • Read committed is the usual default and allows non-repeatable reads and lost updates.
  • Serialisable is the only level that removes write skew; it costs retries or blocking.
  • Optimistic control wins on low contention; pessimistic wins where conflicts are frequent.
  • A uniqueness constraint or a conditional update is often cheaper than a heavier isolation level.

Checkpoint

Answer all 3 questions to finish the lesson.
1. Two doctors each remove themselves from the on-call list, breaking the 'at least one' rule. Which anomaly is this?
Each transaction writes a different row and reads the other's data without conflicting. Snapshots allow both, so the shared invariant breaks.
2. Which isolation level removes write skew?
Only serialisable guarantees that the outcome equals some serial order, which detects and aborts the conflicting pair.
3. `UPDATE seats SET taken = true WHERE id = 5 AND taken = false` is attractive because:
A conditional update collapses read-check-write into a single atomic operation and reports the affected row count, which is the cheapest correct booking primitive.