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.
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.
| Anomaly | What happens | Real-world shape |
|---|---|---|
| Dirty read | T1 reads a row T2 has changed but not yet committed. | A report includes an order that is rolled back a second later. |
| Non-repeatable read | T1 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 read | T1 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 update | T1 and T2 both read x=10, both write x=11. One increment is gone. | Inventory decremented twice, counted once. |
| Write skew | T1 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
| Level | Dirty read | Non-repeatable | Phantom |
|---|---|---|---|
| READ UNCOMMITTED | allowed | allowed | allowed |
| READ COMMITTED | no | allowed | allowed |
| REPEATABLE READ | no | no | allowed (standard) |
| SERIALIZABLE | no | no | no |
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 UPDATEread the latest committed version, so lost updates are possible unless you lock.
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
-- 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
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 updateUPDATE items SET stock = stock - 1 WHERE id = 7 AND stock > 0 RETURNING stock; -- 0 rows returned → sold out, no double sale possible - 2
Pessimistic lock when the decision needs several steps.
SELECT … FOR UPDATEtakes a row lock inside the transaction; a second session blocks at its ownFOR UPDATEuntil the first commits, then sees the new value.book a seatBEGIN; 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
Optimistic lock when contention is rare. Carry a
versioncolumn; 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 concurrencyUPDATE 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
await prisma.$transaction(
async tx => { /* ... */ },
{isolationLevel: 'Serializable'},
);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.
- 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.