system-design

Isolation Levels & Anomalies

Your transactions are almost certainly not fully isolated, and the bugs this causes are rare, non-reproducible, and appear only under load. Here’s exactly what your database does and doesn’t guarantee.

Prerequisites: Transactions Time to read: ~24 minutes


The problem

Full isolation — serializability — means concurrent transactions produce the same result as if they’d run one at a time. It’s what everyone assumes they have.

It’s also expensive. Enforcing it requires either extensive locking (killing concurrency) or significant bookkeeping. So databases offer weaker levels that allow specific anomalies in exchange for throughput.

🚨 And they don’t default to the strong one:

Database Default isolation level
PostgreSQL Read Committed
MySQL (InnoDB) Repeatable Read
Oracle Read Committed
SQL Server Read Committed
CockroachDB Serializable

So unless you changed it, your transactions are not serializable. Most engineers don’t know this, and it’s the source of a category of bug that’s genuinely miserable to debug: it happens once a week, only in production, only under load, and never reproduces.


The anomalies

Learn these by their symptoms. Each is a specific way concurrent transactions can produce a result that no serial ordering could.

Dirty read — reading uncommitted data

T1: UPDATE accounts SET balance = 400 WHERE id = 1;    -- not committed
T2:   SELECT balance FROM accounts WHERE id = 1;       -- reads 400
T1: ROLLBACK;                                          -- that 400 never existed

T2 acted on data that was never real. Prevented by every level above Read Uncommitted, so you will essentially never see this in practice.

Non-repeatable read — the same row changes mid-transaction

T1: SELECT balance FROM accounts WHERE id = 1;   -- 500
T2:   UPDATE accounts SET balance = 400 WHERE id = 1; COMMIT;
T1: SELECT balance FROM accounts WHERE id = 1;   -- 400  ← different!

🚨 This is allowed at Read Committed — the default in Postgres and Oracle. If your transaction reads a value, does some logic, and reads it again, the two reads can disagree. Any report that sums a column twice, or any validation that re-checks a value, can behave inconsistently.

Phantom read — the set of matching rows changes

T1: SELECT COUNT(*) FROM orders WHERE status = 'pending';   -- 10
T2:   INSERT INTO orders (status) VALUES ('pending'); COMMIT;
T1: SELECT COUNT(*) FROM orders WHERE status = 'pending';   -- 11  ← a new row appeared

Not a changed row — a new row matching your predicate. Harder to prevent, because you’d have to lock rows that don’t exist yet (predicate locks or range locks).

Lost update — two writers, one survives

T1: SELECT quantity FROM inventory WHERE id=1;   -- 10
T2: SELECT quantity FROM inventory WHERE id=1;   -- 10
T1: UPDATE inventory SET quantity = 9 WHERE id=1;  COMMIT;
T2: UPDATE inventory SET quantity = 9 WHERE id=1;  COMMIT;
-- Two items sold, quantity went from 10 to 9. One sale vanished.

🚨 The most common real-world transaction bug, and it happens at Read Committed.

Three fixes:

  1. Atomic update: UPDATE inventory SET quantity = quantity - 1 WHERE id=1 AND quantity > 0. The database does the read-modify-write internally. Prefer this.
  2. Pessimistic lock: SELECT ... FOR UPDATE before reading.
  3. Optimistic: a version column, checked in the WHERE clause. → Transactions

Write skew — the subtle one

🚨 The anomaly that catches out even experienced engineers, because each transaction is individually valid.

Rule: at least one doctor must always be on call.
Currently: Ayesha and Bilal are both on call.

T1 (Ayesha): SELECT COUNT(*) FROM doctors WHERE on_call = true;   -- 2, fine
T2 (Bilal):  SELECT COUNT(*) FROM doctors WHERE on_call = true;   -- 2, fine
T1: UPDATE doctors SET on_call = false WHERE name = 'ayesha'; COMMIT;
T2: UPDATE doctors SET on_call = false WHERE name = 'bilal';  COMMIT;

-- Zero doctors on call. Both transactions individually respected the rule.

Note they wrote to different rows, so there’s no lost update and no lock conflict. Each read a premise that the other invalidated.

Where it appears in real systems:

Only Serializable isolation prevents write skew automatically. Otherwise you need a materialized conflict (lock a shared row that both transactions must touch) or a database constraint that makes the invalid state impossible.


The levels

Level Dirty read Non-repeatable read Phantom Lost update Write skew
Read Uncommitted ❌ possible
Read Committed ✅ prevented
Repeatable Read ❌ (mostly) ✅ (mostly)
Serializable

🚨 “Mostly” is doing real work in that table, because implementations differ significantly:

PostgreSQL’s Repeatable Read is actually snapshot isolation — it prevents phantoms too (you see one consistent snapshot for the whole transaction) and detects lost updates, aborting one transaction with a serialization error. It’s stronger than the SQL standard requires. But it still allows write skew.

MySQL’s Repeatable Read uses next-key locking, which prevents phantoms for locking reads but has different semantics for plain SELECT. It’s a genuinely different behaviour from Postgres under the same name.

🚨 The lesson: the standard’s names are unreliable. Know what your database does. Saying that in an interview is a strong signal — it’s the kind of thing you only learn by being burned.


Serializable, and its two implementations

Two-phase locking (2PL) — the classic. Acquire read locks for reads and write locks for writes, hold them all until commit, and use predicate/range locks to prevent phantoms. Correct, and slow: readers block writers, contention is high, and deadlocks are frequent. Used by SQL Server and MySQL’s SERIALIZABLE.

Serializable Snapshot Isolation (SSI) — the modern approach, used by PostgreSQL and CockroachDB. Optimistic: run transactions on snapshots with no locking, but track read/write dependencies. At commit, if the database detects a pattern that couldn’t have arisen from any serial ordering, it aborts one transaction.

No locking, so readers never block. Performance is close to snapshot isolation when conflicts are rare. ❌ Transactions can fail at commit with a serialization error — 🚨 your application must retry them. Code that doesn’t handle 40001 will fail intermittently.

📐 SSI’s overhead is often only 5–20% under low contention, which makes SERIALIZABLE far more practical in Postgres than most people assume. It’s worth considering as a default for correctness-critical workloads, with retry logic in place.

def with_retry(fn, attempts=3):
    for i in range(attempts):
        try:
            with db.transaction(isolation='serializable'):
                return fn()
        except SerializationFailure:
            if i == attempts - 1:
                raise
            time.sleep(0.05 * (2 ** i) * random.random())   # backoff + jitter

Choosing a level

Workload Level Reasoning
Reporting, analytics, dashboards Read Committed Perfect isolation isn’t needed; throughput matters
Ordinary CRUD Read Committed + atomic updates Handle lost updates explicitly per operation
Reading multiple related tables consistently Repeatable Read / Snapshot One consistent view for the whole transaction
Money, inventory, booking, any invariant across rows Serializable (+ retries) Write skew is a real risk and the cost of being wrong is high

🎙️ The nuanced answer interviewers like: “I’d use Read Committed as the default for throughput, but Serializable for the booking transaction specifically — that’s a check-then-write pattern, which is exactly where write skew bites, and double-booking a room costs more than the isolation overhead. It means handling serialization failures with a retry, which we’d need anyway for deadlocks.”

Per-transaction isolation is normal. You don’t pick one level for the whole application:

BEGIN ISOLATION LEVEL SERIALIZABLE;
  -- just the booking logic
COMMIT;

Practical patterns for common problems

Preventing lost updates — prefer the atomic update:

-- ✅ One statement, no race possible
UPDATE inventory SET quantity = quantity - 1
WHERE product_id = 42 AND quantity > 0;
-- 0 rows affected → out of stock. No lock, no retry, no isolation level needed.

Preventing double-booking — use a constraint, not application logic:

-- The database makes the invalid state impossible
ALTER TABLE bookings ADD CONSTRAINT no_overlap
  EXCLUDE USING gist (room_id WITH =, during WITH &&);

🚨 A constraint beats an isolation level every time. It’s enforced regardless of transaction level, application bugs, or which service wrote the row. If you can express an invariant as a constraint, do.

Materializing a conflict when you can’t use a constraint: have both transactions update a shared row (e.g. a room_locks row) so they genuinely conflict and one is forced to wait or abort.

UniquenessUNIQUE constraints work at any isolation level. Don’t write SELECT ... IF NOT EXISTS THEN INSERT; that’s a write skew waiting to happen. Use INSERT ... ON CONFLICT DO NOTHING and handle the result.


⚖️ Trade-offs

Level Gain Cost
Read Committed Highest throughput, no retries Non-repeatable reads, phantoms, lost updates, write skew
Repeatable Read / Snapshot Consistent view; lost updates detected Still allows write skew; more version retention
Serializable (2PL) Full correctness Locking, blocking, deadlocks, low concurrency
Serializable (SSI) Full correctness, no locking Transactions abort at commit; must implement retries
Constraints Enforced always, regardless of level Not every invariant can be expressed as one

In the real world


🚨 Interview traps


🎙️ Soundbites


🛠️ Try it

These are quick and they’ll change how you write transactions permanently. Open two psql sessions.

1. Non-repeatable read at the default level:

-- Session 1                                 -- Session 2
BEGIN;
SELECT balance FROM accounts WHERE id=1;     -- 500
                                             UPDATE accounts SET balance=400 WHERE id=1;
                                             COMMIT;
SELECT balance FROM accounts WHERE id=1;     -- 400  ← changed mid-transaction
COMMIT;

Repeat with BEGIN ISOLATION LEVEL REPEATABLE READ and watch it return 500 both times.

2. Write skew — the important one:

CREATE TABLE doctors (name TEXT PRIMARY KEY, on_call BOOLEAN);
INSERT INTO doctors VALUES ('ayesha', true), ('bilal', true);

-- Session 1                                      -- Session 2
BEGIN ISOLATION LEVEL REPEATABLE READ;            BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call;  -- 2 SELECT count(*) FROM doctors WHERE on_call; -- 2
UPDATE doctors SET on_call=false                  UPDATE doctors SET on_call=false
  WHERE name='ayesha';                              WHERE name='bilal';
COMMIT;                                           COMMIT;

SELECT count(*) FROM doctors WHERE on_call;  -- 0  ← invariant violated

Now run the exact same sequence with SERIALIZABLE and watch the second transaction abort with could not serialize access due to read/write dependencies. Seeing that error fire is the moment this chapter becomes real.

3. Lost update, and the one-line fix. Two sessions doing read-then-write on a counter. Watch one update vanish. Then use UPDATE t SET n = n - 1 and watch it become correct with no isolation change.


Check yourself

1. What is write skew, and why is it harder to spot than a lost update? Two transactions read an overlapping set of rows, each makes a decision based on what it read, and each writes to a *different* row — jointly violating an invariant that each individually preserved. The doctors-on-call example: both see two doctors on call, each removes a different one, and now zero are on call. It's harder to spot than a lost update because there's no write-write conflict — the transactions touch different rows, so no lock conflicts, nothing is overwritten, and each transaction is individually correct. Only serializable isolation detects it automatically, by tracking read/write dependencies.
2. What isolation level does your database default to, and what does that allow? Postgres, Oracle, and SQL Server default to **Read Committed**; MySQL/InnoDB to **Repeatable Read**. Read Committed prevents only dirty reads — it still allows non-repeatable reads (the same row changes mid-transaction), phantoms (new rows appear), lost updates, and write skew. So the common assumption that "I'm in a transaction, therefore I'm isolated" is wrong by default. The practical implication: any read-modify-write in application code needs an atomic statement, a lock, or a version check — the transaction alone doesn't protect you.
3. What's the difference between 2PL and SSI for implementing serializability? **2PL** is pessimistic: acquire shared locks for reads and exclusive locks for writes, hold them until commit, and use predicate/range locks to block phantoms. Correct but slow — readers block writers, concurrency drops, deadlocks are frequent. **SSI** is optimistic: transactions run on snapshots without locking, while the database tracks read/write dependencies; at commit it aborts any transaction whose dependencies form a pattern impossible under serial execution. SSI gives near snapshot-isolation performance under low contention, at the cost that transactions can fail at commit — so the application must retry with backoff. Postgres and CockroachDB use SSI.
4. How do you prevent double-booking a meeting room? Best: a database constraint that makes the invalid state impossible — in Postgres, an `EXCLUDE USING gist` constraint on `(room_id WITH =, during WITH &&)` rejects any overlapping booking regardless of isolation level, application bugs, or which service inserted the row. Alternatives: `SERIALIZABLE` isolation, which detects the write-skew pattern and aborts one transaction (needs retry logic); or materializing the conflict by having both transactions lock a shared row for that room, forcing them to serialize. What doesn't work is `SELECT` to check availability then `INSERT` at Read Committed — that's textbook write skew.
5. Why must an application using SERIALIZABLE in Postgres implement retries? Because Postgres uses SSI, which is optimistic — it lets transactions run without locking and detects conflicts at commit time. When it finds a dependency pattern that couldn't have arisen from any serial ordering, it aborts one transaction with `ERROR: could not serialize access due to read/write dependencies` (SQLSTATE 40001). That's not a bug or an error condition to surface to the user; it's the normal mechanism by which serializability is enforced. The transaction should be retried, with exponential backoff and jitter to avoid repeated collisions. Note you need essentially the same retry logic for deadlocks at any isolation level, so it isn't extra work.

Further reading