Hirestack
Hirestack
Chapter 4

Transactions and isolation levels

📖 14 min read · 11 sections · May 2026

Why this matters

This is the single most misunderstood topic in databases. Even the SQL standard is wrong about it. Most engineers have a wrong mental model — including ones with 10 years of experience. Getting this right is a senior-engineer differentiator.

If you've ever:

...this section is for you.

ACID — the actual meanings

LetterReal meaningCommon misunderstanding
AtomicityAll operations in a transaction succeed together or all fail together. No partial commits."Atomic" doesn't mean concurrency-safe. Two atomic transactions running concurrently can still corrupt each other.
ConsistencyApplication-defined invariants (foreign keys, CHECK constraints, etc.) hold before and after each transaction.This C is not the C in CAP. Different concepts.
IsolationConcurrent transactions don't see each other's intermediate states. How much isolation is parameterised by the isolation level."Isolated" doesn't mean serializable; different levels give different guarantees.
DurabilityOnce a transaction commits, its effects survive crashes.Doesn't protect against admin deleting the database or earthquake destroying both copies.

The "C" is the most-misunderstood. Consistency in ACID just means: if your transaction code is correct (preserves invariants), the database won't break them. The database doesn't define what consistency means — your application does. This is unlike "consistency" in CAP theorem, which means linearizability of operations across replicas.

The isolation levels — what each actually prevents

The SQL standard defines four isolation levels by which anomalies they prevent. The standard is incomplete; real implementations differ.

LevelDirty readsNon-repeatable readsPhantomsLost updatesWrite skew
Read Uncommitted
Read Committed✓ prevented
Repeatable Read (standard)
Snapshot Isolation
Serializable

The anomalies in plain English with concrete examples

Dirty read: Reading uncommitted data.

T1: BEGIN; UPDATE accounts SET balance = 100 WHERE id = 1;
T2: SELECT balance FROM accounts WHERE id = 1;  -- sees 100
T1: ROLLBACK;
-- T2 acted on data that never existed.

Almost never allowed in production databases. Most don't even support this isolation level by default.

Non-repeatable read: A row's value changes within a transaction.

T1: BEGIN; SELECT balance FROM accounts WHERE id = 1;  -- 1000
T2: BEGIN; UPDATE accounts SET balance = 500 WHERE id = 1; COMMIT;
T1: SELECT balance FROM accounts WHERE id = 1;  -- 500 (different!)

If T1 was, say, generating a report, the report has inconsistent numbers.

Phantom read: A range query returns different rows on repeat.

T1: BEGIN; SELECT count(*) FROM orders WHERE amount > 100;  -- 5
T2: INSERT INTO orders (amount) VALUES (150); COMMIT;
T1: SELECT count(*) FROM orders WHERE amount > 100;  -- 6

The range result set "grew" between reads.

Lost update: Two transactions reading the same value and both writing back lose one update.

T1: BEGIN; SELECT counter FROM stats WHERE id = 1;  -- 10
T2: BEGIN; SELECT counter FROM stats WHERE id = 1;  -- 10
T1: UPDATE stats SET counter = 11 WHERE id = 1; COMMIT;
T2: UPDATE stats SET counter = 11 WHERE id = 1; COMMIT;  -- T1's increment lost

Two reads of 10, two writes of 11. Net: one increment instead of two. This is the bug that bites every developer who writes "read-modify-write" code at Read Committed isolation without locking.

Write skew: Two transactions each read a set of rows, check an invariant, then update different rows in a way that violates the invariant when combined.

Setup: on_call doctors = {Alice, Bob}; invariant: ≥1 must be on call.

T1 (Alice marks self off-call):
  BEGIN;
  SELECT count(*) FROM doctors WHERE on_call = true;  -- 2
  -- 2 ≥ 1, so it's safe to remove me
  UPDATE doctors SET on_call = false WHERE id = 'alice';
  COMMIT;

T2 (Bob marks self off-call, CONCURRENT):
  BEGIN;
  SELECT count(*) FROM doctors WHERE on_call = true;  -- 2 (snapshot from before T1)
  -- 2 ≥ 1, so it's safe
  UPDATE doctors SET on_call = false WHERE id = 'bob';
  COMMIT;

Result: zero doctors on call. Invariant violated.

Critically: Snapshot Isolation does NOT prevent write skew. Each transaction saw a consistent snapshot showing the invariant was satisfied. Neither read each other's update.

Analogy for write skew
Two roommates, each looking in the fridge at 6 AM, see there's milk and decide they don't need to buy milk on the way home. Each makes that decision based on what they saw — a consistent observation. But the combined result of their decisions (neither bought milk) violates the invariant (the household needs milk). They didn't communicate; their decisions didn't conflict on any single object; but the result is wrong.

Real-world write-skew scenarios

What real databases actually do

Postgres

MySQL InnoDB

Oracle

SQL Server

Cassandra / DynamoDB

How to actually prevent the anomalies you care about

Lost updates

The most common bug. Three fixes:

Write skew

Three fixes:

MVCC in depth — how readers and writers stay non-blocking

MVCC (Multi-Version Concurrency Control) is how modern databases achieve "writers don't block readers." Each row stores metadata:

Each transaction has a "snapshot" — the set of transaction IDs visible to it. A row version is visible if:

xmin is in snapshot (the creating transaction is committed and visible)
AND
(xmax is null OR xmax is not in snapshot)
  -- the deleting transaction doesn't exist OR isn't visible

Updates don't modify in place. They:

  1. Mark the old row version's xmax as the current transaction ID.
  2. Insert a new row version with xmin = current transaction.
  3. Both versions exist; readers see whichever matches their snapshot.

The cost: dead tuples and VACUUM

Updates and deletes create dead row versions. They take space, slow scans, and must be reclaimed.

Postgres VACUUM:

The critical issue: a long-running transaction holds an old snapshot. VACUUM cannot reclaim any row version newer than the oldest snapshot. Tables can balloon under one analyst's 1-hour query.

War story — the "long transaction" outage
Multiple companies have written about this exact failure: a developer runs BEGIN in psql, walks away for lunch, comes back to find autovacuum hasn't worked for an hour, table sizes have doubled, queries are crawling, and disk usage alerts are firing. The fix is one COMMIT away but the system is degraded for hours afterward as VACUUM catches up. Always: explicit transaction timeouts (idle_in_transaction_session_timeout in Postgres), avoid long-running transactions on production OLTP DBs, run analytics on replicas or a warehouse.

Pessimistic vs Optimistic locking

PessimisticOptimistic
HowSELECT ... FOR UPDATE: take lock when reading; hold until commitRead version number; on update UPDATE ... WHERE version = $old_version; retry on 0 rows affected
When it worksHigh contention; short transactionsLow contention; long transactions; can't hold locks (HTTP request)
When it breaksLong-held locks block others; deadlocksHigh contention → endless retries (livelock)
ExampleInventory reservation in checkoutREST API updating user profile

Distributed transactions

The pain begins when a transaction spans multiple shards or services.

Two-Phase Commit (2PC)

sequenceDiagram
    participant C as Coordinator
    participant P1 as Participant 1
    participant P2 as Participant 2

    Note over C,P2: Phase 1 — Prepare
    C->>P1: PREPARE
    C->>P2: PREPARE
    P1->>P1: write log, hold locks
    P2->>P2: write log, hold locks
    P1-->>C: VOTE-YES
    P2-->>C: VOTE-YES

    Note over C,P2: Phase 2 — Commit
    C->>C: log COMMIT decision
    C->>P1: COMMIT
    C->>P2: COMMIT
    P1-->>C: ACK
    P2-->>C: ACK

    Note over C,P2: If any P votes NO or times out — coordinator sends ABORT instead
Phase 1 — Prepare:
  Coordinator → Participants: PREPARE
  Each participant: persists "I'm ready to commit" to disk
  Participants → Coordinator: PREPARED (or ABORT)

Phase 2 — Commit:
  Coordinator → Participants: COMMIT (or ROLLBACK)
  Participants execute, ack.

Correct, but: if the coordinator dies between PREPARE and COMMIT, participants are stuck. They've voted to commit but don't know whether to actually commit or roll back. This is the "blocking" property of 2PC.

Used by: XA transactions, some message queues with DB integration. Avoided in modern systems because of the blocking property and operational complexity.

Saga pattern

Break the distributed transaction into a sequence of local transactions, each with a compensating action that undoes it.

Booking a trip:
  Step 1: BookHotel       → on failure later: CancelHotel
  Step 2: BookFlight      → on failure later: CancelFlight, CancelHotel
  Step 3: ChargeCard      → on failure later: RefundCard, CancelFlight, CancelHotel
  Step 4: SendConfirmation

Properties:

Two implementation styles:

The outbox pattern

You want to atomically (a) write to your DB and (b) publish an event to Kafka. You can't 2PC between Postgres and Kafka practically. So:

BEGIN;
  INSERT INTO orders (...);  -- the business write
  INSERT INTO outbox (event_type, payload) VALUES ('order.placed', '{...}');
COMMIT;

-- Separate worker:
SELECT * FROM outbox WHERE processed_at IS NULL FOR UPDATE SKIP LOCKED LIMIT 100;
-- For each row: publish to Kafka; UPDATE outbox SET processed_at = now() WHERE id = ?

The DB write and the outbox insert are atomic (one transaction). The worker reads from the outbox and publishes. If the worker crashes after publishing but before marking processed, the event is published twice — so Kafka consumers must be idempotent (deduplicate by event_id).

This is the foundation of "event-driven" architecture without distributed transactions. Used by Stripe, every payments company, most fintech, most CDC-driven systems.

Further reading — transactions and isolation

Interview drills

Q: What's the default isolation level in Postgres, and what does it actually guarantee?
Default is Read Committed. It prevents dirty reads (you never see uncommitted data from other transactions). It does NOT prevent non-repeatable reads, phantoms, lost updates, or write skew. Every SELECT sees a fresh snapshot at the moment the statement starts — so two reads in the same transaction can return different data. If you do SELECT balance FROM account WHERE id = 1 then UPDATE account SET balance = $new WHERE id = 1, you have a lost-update bug if anyone else writes to this row concurrently.
Cross-Q: How do you fix the lost-update bug without changing isolation level?
A: Two options. (1) SELECT ... FOR UPDATE on the read — takes an exclusive lock; other transactions block. (2) Make the update atomic: UPDATE account SET balance = balance - 100 WHERE id = 1 AND balance >= 100. The atomic update is cheaper because no row lock is held across multiple statements.
Cross-Q: When would you use FOR UPDATE vs the atomic option?
A: Atomic when you can express the update as a single SQL statement. FOR UPDATE when you need to read, do app logic, then write — e.g., checking inventory then decrementing. The lock prevents another transaction from reading the same value.
Q: Explain MVCC and what makes it expensive.
MVCC stores every row with metadata (xmin, xmax). Reads see the version visible at the transaction's snapshot. Writers don't modify in place — they create new versions. So writers don't block readers, and readers don't block writers. Cost: dead row versions accumulate; VACUUM (Postgres) or undo log (MySQL) must reclaim. Long transactions hold old snapshots that prevent reclamation — table bloat, slower queries, eventual disk pressure.
Cross-Q: Why does MVCC make Postgres heap larger than MySQL InnoDB for the same data?
A: Postgres stores dead tuples in the heap until VACUUM reclaims them. InnoDB moves old versions to a separate "rollback segment" — the heap stays compact. So Postgres has heap bloat issues; InnoDB has rollback segment growth issues. Different operational profiles, same conceptual idea.
Cross-Q: How does HOT (Heap-Only Tuples) help in Postgres?
A: If the indexed columns aren't changing and there's free space on the page, Postgres can update without touching the index — just write a new tuple version on the same page with a pointer from the old version. Reduces write amplification dramatically. Requires fillfactor < 100% to leave room on the page.
Q: Implement idempotent payment processing.
CREATE TABLE idempotency_keys (
  key UUID PRIMARY KEY,
  request_hash TEXT NOT NULL,
  response JSONB,
  status TEXT NOT NULL,  -- 'pending' | 'complete' | 'failed'
  expires_at TIMESTAMPTZ
);

-- In application code:
BEGIN;
  -- Atomic claim or fetch
  INSERT INTO idempotency_keys (key, request_hash, status, expires_at)
  VALUES ($1, $2, 'pending', now() + interval '24 hours')
  ON CONFLICT (key) DO UPDATE SET request_hash = idempotency_keys.request_hash
  RETURNING *;
  -- Check if this is a retry of a different request with same key
  -- Check if status is 'complete' → return stored response
COMMIT;

-- If we got the row (new request): process the payment
response = call_payment_provider(...);

BEGIN;
  UPDATE idempotency_keys SET response = $1, status = 'complete' WHERE key = $2;
COMMIT;
RETURN response;
Cross-Q: What if the server crashes after processing but before storing the response?
A: The next request with the same key sees a 'pending' row. Two options: (a) re-query the payment provider with the same idempotency key (providers like Stripe support this — they remember the result). (b) Implement a saga compensation. Production systems do (a). This is why every payment provider supports idempotency keys end-to-end.
Cross-Q: Why ON CONFLICT DO UPDATE instead of DO NOTHING?
A: So the RETURNING clause returns the row whether we inserted or it already existed — single round trip. Could also use a separate SELECT but that's two queries.
Q: Implement an outbox pattern with per-aggregate ordering.
CREATE TABLE outbox (
  id BIGSERIAL PRIMARY KEY,
  aggregate_id UUID NOT NULL,
  event_type TEXT NOT NULL,
  payload JSONB NOT NULL,
  processed_at TIMESTAMPTZ
);
CREATE INDEX idx_outbox_unprocessed ON outbox (aggregate_id, id)
  WHERE processed_at IS NULL;

-- Business write + outbox in one transaction
BEGIN;
  INSERT INTO orders (...);
  INSERT INTO outbox (aggregate_id, event_type, payload)
  VALUES ($1, 'order.placed', $2);
COMMIT;

-- Worker, one per aggregate (or sharded by aggregate_id)
SELECT * FROM outbox
WHERE processed_at IS NULL
  AND aggregate_id = ANY($1)  -- worker's assigned aggregates
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;
-- For each: publish to Kafka with key = aggregate_id (preserves Kafka partition ordering)
-- Mark processed
Per-aggregate ordering: events for the same aggregate processed by one worker in id order. Across aggregates, parallel.
Cross-Q: What if the worker publishes to Kafka but crashes before marking processed?
A: Same event published twice (next worker run re-processes). Consumers must deduplicate by event_id. This is the at-least-once delivery guarantee; exactly-once requires significantly more complexity.
Cross-Q: Why FOR UPDATE SKIP LOCKED?
A: Multiple workers run concurrently; SKIP LOCKED makes each pick up rows not locked by others. Without it, workers contend on the same rows.
Q: Two users register the same username at the same moment in Read Committed. What happens?
Classic write-skew style bug. Naive implementation: SELECT id FROM users WHERE username = 'foo'; -- no row → INSERT INTO users (username) VALUES ('foo'). Two transactions both see no row; both insert. Result: duplicate usernames. Fix: UNIQUE constraint on username. One INSERT succeeds; the other gets a constraint violation. Or use INSERT ... ON CONFLICT DO NOTHING RETURNING id and check RETURNING.
Cross-Q: Why is UNIQUE constraint better than SELECT-then-INSERT under any isolation level?
A: The DB engine implements the UNIQUE check atomically with the insert via index. Application-level "check then insert" can never be atomic with simple SQL; the constraint is the only way to be sure.

Red flags