Heads up: posts on this site are drafted by Claude and fact-checked by Codex. Both can still get things wrong — read with care and verify anything load-bearing before relying on it.
why → how

Why database isolation levels exist

Only the top level is simply correct. The others exist because correctness costs money — and the standard's list of what can go wrong turned out to be one entry short.

Data intermediate Aug 25, 2026 · 14 min read

On this page

The picture version

The whole idea in seven pictures, for a reader who has never thought about two things happening in a database at once. The prose below fills in the seams the pictures skip.

1 · The problem

Two honest withdrawals. One broken rule.

the bank's rule: checking + savings ≥ 0 you, at a card machine your flatmate, at a cash machine CHECKING £100 SAVINGS £100 withdraws £150 14:32:07 withdraws £150 14:32:07 “after me: 50 ≥ 0. fine.” “after me: 50 ≥ 0. fine.” TOTAL: −£100 · RULE BROKEN
Each withdrawal checked the rule and each was right. Neither one could see the other. Individually correct, jointly wrong — that gap is the whole subject.

2 · The safe way nobody can afford

Let one transaction in at a time.

the turnstile inside: nothing can surprise it every other card tap in the bank, waiting CORRECT UNUSABLE
Run transactions strictly one after another and every anomaly disappears by construction — along with the throughput. Everything that follows is a discount on this.

3 · Surprise one

You read a change that then un-happened.

you flatmate writes savings = −50 not committed yet reads −50 leaks across ROLLBACK the −50 never existed …so you decline a card that had the money time
A dirty read: acting on a number from a transaction that hadn't committed — and then didn't. Ban it and you have the level called READ COMMITTED.

4 · Surprise two

You ask twice and get two answers.

you flatmate read savings → decide £100 read savings → receipt −£50 one transaction, still open COMMIT −£150 lands mid-transaction time
A non-repeatable read: same row, same transaction, two different answers, so your decision and your receipt disagree. Ban it and you have REPEATABLE READ.

5 · Surprise three

You can't hold still a row that doesn't exist yet.

you flatmate SELECT SUM(balance) FROM pots WHERE account = 42 £200 two pots, both held still £350 three pots same query, same transaction INSERT a third pot nothing was there to hold time
A phantom read. The fix is to hold still the question — the whole space of rows the WHERE clause covers, including rows nobody has written yet. That is what the standard calls SERIALIZABLE.

6 · The twist

All three banned. The money is still gone.

THE STANDARD'S LIST dirty read non-repeatable read phantom read ✓ ✓ ✓ your frozen snapshot £100 + £100 their frozen snapshot £100 + £100 writes writes two different rows CHECKING −£50 SAVINGS −£50 neither write is in the other's snapshot TOTAL: −£100 · WRITE SKEW
This is write skew, and it is why the standard's three-item list was never a definition of correctness. Ban everything on a list and you are one entry short forever — the top level has to be defined by what it guarantees: a result some single-file order could have produced.

7 · Keep this card

The whole thing on one index card.

ISOLATION LEVEL = how much “run them one at a time” you're paying for ↓ cheaper = more surprises get through ↑ the top rung is a promise about the result, not about the schedule
Picture to keep: a turnstile. The strictest setting admits one at a time and the queue outside is long; every weaker setting is a wider gate — more people through per second, and one more way for two of them to trip over each other. The bill for the strict setting comes as waiting (locks) or as retries (conflict detection). Never as nothing.

Why it exists

You and your flatmate share a bank account with two pots: checking and savings, £100 in each. The bank’s rule is the friendly kind — either pot may go negative, as long as the two together stay at or above zero. At 14:32:07 you tap your card for £150 out of checking. At 14:32:07 your flatmate withdraws £150 from savings at a cash machine across town.

Both requests do the honest thing. Yours reads both pots, sees £100 + £100, works out that after your £150 the pair sits at £50, and approves. Theirs reads both pots, sees £100 + £100, works out the same £50, and approves. Neither one lied. Neither one read stale data in any way it could detect. And the account ends the second at minus £100, which the bank’s own rule says is impossible.

Follow this account for the rest of the post. Everything below is about the gap that just opened: each transaction was individually correct, and the pair of them was not.

The clean fix is obvious and unaffordable. Run transactions strictly one at a time — your card tap finishes completely, then the cash machine starts — and your flatmate’s request reads £-50 + £100 and gets declined. Correct, by construction. Also a database that serves one customer at a time, which is not a database anybody can run a bank on.

So every real database does the unaffordable thing partially. An isolation level is how you say how much of it you want.

Why it matters now

You picked one this morning without knowing it. Postgres hands you READ COMMITTED by default; MySQL’s InnoDB hands you REPEATABLE READ; Oracle hands you READ COMMITTED. All three ship defaulting to something weaker than serializable, because serializable is the expensive one and most workloads never notice the difference. Most.

The three places this stops being academic: money and inventory (the joint account above, the last seat on a flight, a coupon redeemable once), any “check-then-act” rule your application enforces in SQL rather than in a constraint, and any migration between engines — because, as the seams below get into, REPEATABLE READ in Postgres and REPEATABLE READ in MySQL are not the same product with the same name. They are different products with the same name.

The short answer

isolation level = how much "run them one at a time" you're paying for

Picture to keep: a turnstile at the top of the stairs. The strictest setting is a turnstile that admits one person at a time — nothing surprising can happen on the stairs, and the queue outside is long. Every weaker setting is a wider gate: more people through per second, and one more way for two of them to trip over each other on the way up.

Where that picture breaks, and it’s the useful part: the strictest level does not actually make transactions take turns. It promises that the result is one that some single-file order could have produced. How the engine keeps that promise while still running everything at once is the whole of How it works.

How it works

Build the ladder from the failures. Keep the joint account in view.

Naive attempt: run transactions one at a time. This is the definition of correctness we want, and it’s called serial execution. Your tap commits, the cash machine reads the real £-50, declines. Perfect. Why it breaks: throughput of one. Every card tap now queues behind whatever long-running report happens to be sweeping the accounts table, because the rule is no overlap, not no overlap with the rows I touch.

Fix: run them concurrently, and let each read whatever is in the row right now. Why it breaks: your flatmate’s withdrawal writes savings down to £-50, then their card is declined for an unrelated reason and the transaction rolls back. Your request read that £-50 in the gap. You get declined on the strength of a withdrawal that never happened. That’s a dirty read — reading a change that later gets undone.

Fix: only ever read data from transactions that committed. That rule has a name: READ COMMITTED — Postgres’s default, and Oracle’s. Why it breaks: your request reads savings twice — once to apply the rule, once to print the receipt. Between the two, your flatmate commits. Same row, same transaction, two different answers. Your decision and your receipt now disagree. That’s a non-repeatable read.

Fix: fix the rows you’ve read for the whole transaction, not just for one statement. That’s REPEATABLE READ. Why it breaks: you don’t read a row, you read a set — SELECT SUM(balance) FROM pots WHERE account = 42. Holding still the rows you already saw does nothing about a row that didn’t exist when you looked. Your flatmate’s bank app opens a third pot mid-transaction and pays £150 of wages into it; you run the same query again and the sum has gone from £200 to £350. There was nothing to hold still, because there was nothing there. That’s a phantom read.

Fix: hold still the query, not the rows — the whole space of rows the WHERE clause describes, including the ones nobody has written yet. With dirty reads, non-repeatable reads and phantoms all ruled out, you’re at the level SQL-92 calls SERIALIZABLE. Those four levels against those three anomalies are the grid at the front of every engine’s concurrency chapter.

So your engine now forbids all three. Go back to 14:32:07 — before you read on, commit to an answer: is the joint account safe?

The list was one entry short

It isn’t safe, and the way it isn’t is the most interesting thing about this topic.

Give both requests a snapshot: a frozen picture of the whole database as of the moment each transaction started — which is exactly what keeping several versions of every row, or MVCC, makes cheap. Now check the list. Dirty read? No — every snapshot is built from committed data. Non-repeatable read? No — the snapshot doesn’t move for the transaction’s whole life. Phantom? No — a row created after your snapshot simply isn’t in your picture. All three anomalies are gone, and the account still ends the second at minus £100, because you and your flatmate each read a consistent picture, each wrote a different row, and neither write was visible to the other’s snapshot. Nothing on the list was violated. The money is gone anyway. Look again at Postgres’s version of that grid and you’ll find a fourth column beside the three anomalies, headed serialization anomaly. It has to be there because the three don’t add up to correctness — and the standard knew it, which is why SQL-92 defines SERIALIZABLE positively (the result must match some serial order) rather than as the bottom row of the anomaly table.

That anomaly is write skew, and this is not a hypothetical someone thought up later. It’s the argument of Berenson, Bernstein, Gray, Melton, O’Neil and O’Neil’s 1995 paper A Critique of ANSI SQL Isolation Levels, which named the mechanism snapshot isolation, catalogued write skew as the anomaly A5B, and used — genuinely — a bank constraint over two jointly held balances as the example.

Why did the list fail? Because it was never a definition of correctness — it was a list, and the shape of the list is suspiciously close to the shape of a lock manager. Hold no read locks and you get dirty reads; hold them for one statement and you get non-repeatable reads; hold them until commit and you still get phantoms; lock ranges and you don’t. My read is that the anomalies were picked to describe what the lock-based implementations of the day happened to do; the paper’s own and sharper claim is that the ANSI definitions fail to characterize even those implementations correctly. Either way the moral holds: define safety by enumerating what can go wrong and you are one entry short forever — and snapshot isolation is the proof, because it doesn’t even sit on the ladder. It forbids phantoms, which SQL-92 permits at REPEATABLE READ, and permits write skew, which lock-based REPEATABLE READ forbids. It is not stronger or weaker than that rung. It is off to one side.

Fix: define the top of the ladder positively, by subtraction from nothing. True SERIALIZABLE means: whatever order the engine actually ran things in, the outcome is one that some single-file order would have produced. Not “no dirty reads and no phantoms” — “indistinguishable from taking turns.” That definition has no list to fall off the end of, and there are two families of way to keep it:

That’s the real shape of the bill, and it’s worth carrying: the pessimistic route charges you latency, the optimistic route charges you retry logic. Neither is free, which is why the menu has four items instead of one.

Show the seams

You started with isolation level = how much "run them one at a time" you're paying for. What did this post add about the top of the price list? — that the top item isn’t the one at the top of the standard’s table. Ruling out the three named anomalies is not the same as being correct, and the level worth the name is the one defined by what it guarantees (a result some serial order could have produced) rather than by the list of surprises it happens to forbid. Everything below it is a discount you are choosing to take, and the joint account is the bill arriving.

Check yourself

Before you go — your team is getting could not serialize access due to read/write dependencies errors under load on Postgres SERIALIZABLE, and someone proposes dropping to REPEATABLE READ to make them stop. Do they stop, and what did you just trade?

Answer

Mostly, and the “mostly” matters. REPEATABLE READ still aborts a transaction that updates a row another transaction already updated and committed — you’ll keep seeing could not serialize access due to concurrent update. What disappears is the dependency-based aborts, the ones SSI raises when two transactions’ reads and writes interlock in a pattern no serial order could produce. Write skew is the headline member of that family rather than the whole of it — SSI hunts the dependency structure, not one named anomaly. So the trade is: a loud, retryable failure becomes a silent, wrong answer. The joint account goes back to being able to end the day at minus £100. If the retry rate is the real problem, the cheaper fixes are shorter transactions and fewer rows touched — not a lower ceiling.

And one more — you move the whole application to SERIALIZABLE and the joint account is now safe. Your colleague points out that the mobile app reads the two balances in one API call, shows a confirmation screen, and posts the withdrawal thirty seconds later in a second call. Is that path safe too?

Answer

No, and it was never the database’s job. Those are two separate transactions with a human-length gap between them; the balance you showed on the confirmation screen was committed data at the time and can be stale by the time the write lands. SERIALIZABLE guarantees that each transaction behaves as if it ran alone — it makes no claim about a pair of transactions with your UI in between. The fixes live in the application: re-read and re-check the rule inside the writing transaction, or carry a version number from the read and refuse the write if it changed (optimistic concurrency control), or make it idempotent so a re-submit can be replayed safely. This is the most common way people get burned after correctly reasoning about isolation levels: they secure the window the database can see and forget that the interesting decision happened outside it.

Going deeper