Topics
Data & Storage

Transactions and Isolation Levels

What each isolation level actually prevents, how MVCC lets readers and writers coexist, and the three patterns that stop lost updates and double-bookings.

Intermediate·13 min read·Updated Sep 27, 2026

A transaction groups statements into one all-or-nothing unit. The isolation level decides what that unit is allowed to see of other transactions running at the same time. Modern databases implement it with MVCC: every transaction reads from a snapshot of row versions, so readers never block writers. Higher isolation removes more anomalies, but pays for it with aborted transactions you must retry, or with locks that serialise the hot path.

Why it matters

The bugs isolation levels prevent are the expensive kind: two customers buy the last item in stock, a balance goes negative because two withdrawals read the same starting value, a report sums rows that were half-way through an update. They never show up in tests that run one request at a time and they reproduce only under load, which is exactly when nobody has time to debug them.

The anomalies isolation is defined against

The SQL standard does not define isolation levels by how they work but by which read phenomena they forbid. Two more anomalies that the standard forgot, lost update and write skew, matter more in practice than the ones it names.

AnomalyWhat happensReal-world shape
Dirty readT1 reads a row T2 has changed but not yet committed.A report includes an order that is rolled back a second later.
Non-repeatable readT1 reads a row twice and gets different values because T2 committed in between.Validation passes on the first read, the second read fails the same check.
Phantom readT1 runs the same WHERE twice and gets a different set of rows.“Count bookings for this slot” returns 0, then the insert finds 1.
Lost updateT1 and T2 both read x=10, both write x=11. One increment is gone.Inventory decremented twice, counted once.
Write skewT1 and T2 each read a condition, each write a different row, together they break the invariant.Two on-call doctors both see “2 on call” and both go off call.

The four levels, and what your database really does

LevelDirty readNon-repeatablePhantom
READ UNCOMMITTEDallowedallowedallowed
READ COMMITTEDnoallowedallowed
REPEATABLE READnonoallowed (standard)
SERIALIZABLEnonono

The table is the textbook answer; engines differ from it in ways interviewers like to probe.

  • Defaults. PostgreSQL, Oracle and SQL Server default to READ COMMITTED. MySQL InnoDB defaults to REPEATABLE READ.
  • Postgres has no dirty reads at all. READ UNCOMMITTED behaves exactly like READ COMMITTED, because MVCC never exposes uncommitted versions.
  • Postgres REPEATABLE READ is snapshot isolation: the whole transaction sees one snapshot, so phantoms cannot happen either. Its price is that a write to a row another transaction changed since the snapshot fails with could not serialize access due to concurrent update.
  • MySQL REPEATABLE READ also uses a snapshot for plain reads, but writes and SELECT … FOR UPDATE read the latest committed version, so lost updates are possible unless you lock.
READ COMMITTED · snapshot per statementT1 reads → 100T2 commits 80T1 reads → 80non-repeatable readREPEATABLE READ · snapshot per transactionT1 reads → 100T2 commits 80T1 reads → 100same snapshot, stablesnapshot taken here
The same two reads under READ COMMITTED and REPEATABLE READ. Under READ COMMITTED each statement gets a fresh snapshot, so T1 sees T2's commit between its two reads. Under REPEATABLE READ the snapshot is taken once, at the first statement.

How MVCC makes this work

Instead of overwriting a row in place, an MVCC engine writes a new version tagged with the id of the transaction that created it, and keeps the old one until no snapshot can still see it. A transaction's snapshot is just the set of transaction ids that had committed when the snapshot was taken. Reading means picking, for each row, the newest version whose creator is in that set. That is why:

  • readers never block writers and writers never block readers;
  • two writers to the same row still conflict, because there can be only one newest committed version, so the second waits for the first to commit or abort;
  • old versions pile up until vacuum/purge removes them, which is what a long-running transaction silently prevents.

The lost update, step by step

two sessions, READ COMMITTED (Postgres default)
-- Session A                          -- Session B
BEGIN;                                BEGIN;
SELECT stock FROM items               SELECT stock FROM items
  WHERE id = 7;   -- 1                  WHERE id = 7;   -- 1
UPDATE items SET stock = 0
  WHERE id = 7;
COMMIT;
                                      UPDATE items SET stock = 0
                                        WHERE id = 7;   -- also "sells" it
                                      COMMIT;
-- Both sessions believed they sold the last unit.

Under READ COMMITTED, B's UPDATE waits for A to commit, re-reads the row, sees stock = 0 and still writes 0, because its decision was made from the stale SELECT. Under Postgres REPEATABLE READ the same UPDATE aborts with a serialization error, which is correct but only if your code retries. Neither level fixes the logic for you; they only change whether the mistake is silent.

Three patterns that actually prevent it

  1. 1

    Make the write self-contained. Put the condition and the change in one statement. The database evaluates it against the current row under a lock, so there is no window between read and write.

    atomic conditional update
    UPDATE items SET stock = stock - 1
    WHERE id = 7 AND stock > 0
    RETURNING stock;
    -- 0 rows returned → sold out, no double sale possible
  2. 2

    Pessimistic lock when the decision needs several steps. SELECT … FOR UPDATE takes a row lock inside the transaction; a second session blocks at its own FOR UPDATE until the first commits, then sees the new value.

    book a seat
    BEGIN;
    SELECT status FROM seats WHERE id = 42 FOR UPDATE;
    -- application checks status = 'free', computes price, etc.
    UPDATE seats SET status = 'booked', booked_by = $1 WHERE id = 42;
    INSERT INTO bookings (seat_id, user_id) VALUES (42, $1);
    COMMIT;
  3. 3

    Optimistic lock when contention is rare. Carry a version column; the write succeeds only if nobody bumped it. No lock held across the user's think time, at the cost of a retry on conflict.

    optimistic concurrency
    UPDATE documents
    SET body = $2, version = version + 1
    WHERE id = $1 AND version = $3;   -- $3 = version the client last read
    -- 0 rows updated → someone else saved first; reload and merge
no retry — user sees a 500
await prisma.$transaction(
  async tx => { /* ... */ },
  {isolationLevel: 'Serializable'},
);
retry on serialization failure
for (let attempt = 0; attempt < 3; attempt++) {
  try {
    return await prisma.$transaction(
      async tx => { /* ... */ },
      {isolationLevel: 'Serializable'},
    );
  } catch (e) {
    if (e.code !== 'P2034') throw e; // not a conflict
  }
}
throw new Error('Too much contention');

Pitfalls

  • Holding a transaction open across a network call

    Calling a payment provider or sending an email inside the transaction keeps row locks and the snapshot alive for the whole round trip. Under load that turns into lock queues and, in Postgres, table bloat because vacuum cannot clean versions the open snapshot might still need. Commit first, then call out, and reconcile failures.

  • Assuming REPEATABLE READ prevents lost updates everywhere

    Postgres aborts the second writer; MySQL InnoDB lets it through because locking reads see the latest committed version, not the snapshot. Same level name, opposite outcome. Use an atomic update or FOR UPDATE and stop depending on the level.

  • Read-check-write in application code

    if (item.stock > 0) { item.stock--; save() } is a lost update waiting for two concurrent requests. Anything the ORM reads and writes back as a whole object has this shape. Push the condition into the UPDATE.

  • Deadlocks from inconsistent lock order

    Transaction A locks row 1 then row 2; B locks row 2 then row 1. The database detects the cycle and kills one. Lock rows in a deterministic order (sort ids) and keep transactions short so the window is tiny.

  • Nested transactions that are not transactions

    Most drivers ignore a BEGIN inside a transaction or turn it into a savepoint. Code that expects the inner block to commit independently, for example to record an audit row even if the outer work fails, silently loses it on rollback. Use an explicit savepoint or a separate connection.

Interview questions

Q1Explain the four isolation levels and what each one prevents.

READ UNCOMMITTED allows dirty reads; READ COMMITTED prevents them but a row can change between two reads; REPEATABLE READ keeps rows stable for the transaction but the standard still allows phantoms; SERIALIZABLE makes the outcome equivalent to some serial order. I'd add that Postgres implements REPEATABLE READ as snapshot isolation, which also blocks phantoms, and that its SERIALIZABLE detects write skew and aborts rather than locks.

Q2How would you prevent two users from booking the same seat?

Make the booking write conditional and atomic: UPDATE seats SET status = booked WHERE id = ? AND status = free, and treat zero affected rows as "taken". If the decision needs several steps, SELECT … FOR UPDATE on the seat row inside the transaction so the second booker blocks and then sees it booked. A unique constraint on (seat_id) in bookings is the last line of defence either way.

Q3What is MVCC and what does it buy you?

Multi-version concurrency control keeps several versions of each row, tagged by the transaction that wrote them, and gives every transaction a snapshot that decides which versions are visible. Readers therefore never block writers or vice versa, which is why databases can run at READ COMMITTED with high concurrency. The cost is old versions that must be vacuumed, and writers to the same row still serialise.

Q4What happens when two transactions update the same row under READ COMMITTED?

The second UPDATE blocks until the first commits or aborts. If it committed, the second re-evaluates its WHERE against the new version and applies its change on top, which is why an UPDATE with the condition inside it is safe, and why a value computed from an earlier SELECT is not: that SELECT saw the old version.

Q5What is write skew, and which isolation level fixes it?

Two transactions each read a shared condition, each write a different row, and the combination violates an invariant neither one broke alone, like both on-call doctors signing off. Snapshot isolation allows it because no row was written twice. Only SERIALIZABLE catches it, by tracking read-write dependencies and aborting one side; otherwise you materialise the conflict, say by locking a single row that represents the invariant.

Q6When do you choose optimistic over pessimistic locking?

Optimistic, with a version column, when conflicts are rare and the read-modify-write spans user think time, because holding a lock for minutes is not an option. Pessimistic, with FOR UPDATE, when contention is high and the transaction is short, because retrying repeatedly under contention wastes more than a brief wait. In both cases the transaction itself must stay short.

Q7Why are long-running transactions harmful even if they only read?

Their snapshot pins every row version that existed when it started, so vacuum cannot reclaim space, tables and indexes bloat, and in Postgres the transaction id horizon stops advancing. They also hold any locks they acquired and keep a connection from the pool. Reporting queries belong on a replica or in short batches.

Key takeaways
  • Isolation levels are defined by the anomalies they forbid; the two that hurt most in practice, lost update and write skew, are not in the standard's table.
  • MVCC gives every transaction a snapshot of row versions, so readers and writers never block each other; writers to the same row still serialise.
  • Postgres REPEATABLE READ is snapshot isolation and aborts conflicting writers; MySQL's does not, so never rely on the level name alone.
  • Prevent lost updates with an atomic conditional UPDATE, SELECT … FOR UPDATE, or a version column, not with read-check-write in application code.
  • SERIALIZABLE is the only level that catches write skew, and it only works if every transaction is written to be retried.
  • Keep transactions short and never hold one open across a network call.

Preparing for interviews? DevRecall turns a job description into a prep plan that points at topics like this one.

Start free