Isolation Is a Dial, Not a Promise Every level below the top has a list of bugs it may let through. READ UNCOMMITTED lets through: all 5 bugs READ COMMITTED lets through: 4 of 5 your default, probably REPEATABLE READ lets through: 1 — write skew SERIALIZABLE lets through: none — but you must retry safer slower Nobody picks a level. Everybody inherits one.

Every company gets this ticket eventually. "We sold 3 tickets but only had 1." "This customer was refunded twice." You open the code and it looks right — it reads the stock, checks it's above zero, decrements it, all inside a transaction. It works a hundred times locally. It only breaks in production, only under load.

The code isn't wrong. The assumption is. "Inside a transaction" does not mean "alone." It means you're running at an isolation level — a setting you probably never chose. And every level except the strictest comes with a list of bugs it is officially allowed to let through.

There are five bugs. Learn their names once and this stops being mysterious forever.

First, the gap

A shop keeps stock in one notebook. Two cashiers, Ana and Ben, are serving two customers who both want the last hoodie.

Ana reads the notebook: stock: 1. She turns to bag the hoodie. Ben reads it: stock: 1. He turns to bag one too. Ana writes stock: 0. Ben writes stock: 0.

Every bug in this post lives in one place: the gap THE GAP the fact you read is already going stale Ana Ben reads 1 writes 0 reads 1 writes 0 2 hoodies sold · 1 existed no error · no exception · the notebook says 0, which looks fine

Two hoodies sold, one existed. And notice: nothing recorded that anything went wrong. No error, no exception, no log line. Just wrong data that looks completely normal.

That's the signature of every bug below. And they all live in the same place — the gap between reading something and acting on it. An isolation level is just an answer to one question: what is another transaction allowed to do in that gap?

Isolation is the illusion that your transaction is the only one running. Every level except the strictest sells you a cheaper illusion — and tells you exactly where it cracks.

The five bugs

The four anomalies at a glance 1 · Dirty read you read what a rollback undoes debit ₹5,000 you read it ROLLBACK you acted on a number that never existed stopped by READ COMMITTED and up 2 · Non-repeatable read same query, twice, two answers SUM = 100 someone commits SUM = 140 your report contradicts itself stopped by REPEATABLE READ and up 3 · Phantom read a new row appears that matches your search COUNT = 0 ✓ someone INSERTs you INSERT too room double-booked · both checks were true stopped by SERIALIZABLE (mostly RR too) 4 · Write skew same read, but each writes a different row read: 2 ✓ write row A read: 2 ✓ write row B the rule breaks · nothing conflicted stopped by SERIALIZABLE only

1 · Dirty read — money that was never really sent

A transfer debits ₹5,000 from an account, then hits an error and rolls back. In that window, your fraud job reads the account and sees the lower balance — a number that never existed. It flags the account. A card gets blocked over a transfer that never happened.

You'll rarely meet this one. PostgreSQL doesn't allow dirty reads at all, and MySQL's default is stricter too. It's in every textbook because it's the easiest to explain.

2 · Non-repeatable read — the report that doesn't balance

BEGIN;
SELECT SUM(total) FROM orders;      -- ₹4,20,000
--   ← 40 new orders commit right here
SELECT SUM(qty) FROM order_items;   -- includes those 40
COMMIT;

Revenue misses the 40 orders. Units includes them. The report is off by an amount that depends on when it ran, so it's different every night. Someone spends a day hunting a bug in the accounting logic. There isn't one — the two queries just saw two different moments.

Fix: REPEATABLE READ pins one snapshot for the whole transaction. Nearly free on read-only work.

3 · Phantom read — the rows you counted, plus one

BEGIN;
SELECT COUNT(*) FROM bookings WHERE room = 4 AND slot = '10:00';  -- 0, free ✓
--   ← someone else runs the same check, also sees 0
INSERT INTO bookings VALUES (4, '10:00', 'Ana');
COMMIT;                                    -- and so do they

Both were right when they looked. The room is double-booked.

The difference from bug 2: no row you read changed. A brand-new row appeared. Re-reading what you already had wouldn't have helped — the problem is the row you'd never seen.

Fix: SERIALIZABLE, or a UNIQUE index on (room, slot), which is cheaper and better.

4 · Lost update — the two cashiers, in code

const u = await repo.findOne(id);   // reads credits = 100
u.credits -= 10;                    // ← the gap. a network round trip.
await repo.save(u);                 // writes 90, on top of someone else's 90

This is the most common concurrency bug in application code, because every ORM encourages exactly this shape. One of the two updates vanishes silently.

Fix: one atomic statement (below), or optimistic locking. REPEATABLE READ also catches it. READ COMMITTED does not.

5 · Write skew — the one that will actually get you

Nobody teaches this one, and it's behind most real incidents. Two transactions read the same data, each checks a rule, and each writes to a different row. Neither overwrites the other, so nothing conflicts — but together they break the rule.

Only SERIALIZABLE stops it. Snapshot isolation — which is what most databases call REPEATABLE READ — does not. Full walkthrough next.

Write skew, step by step

A hospital rota with one rule: at least one doctor must stay on call. Two doctors are on call. Both feel ill. Both hit the button in the same second.

-- Both run this exact code, at the same moment.
BEGIN;
SELECT COUNT(*) FROM doctors WHERE on_call = true;   -- 2, so leaving is fine ✓
UPDATE doctors SET on_call = false WHERE name = 'A'; -- Txn A writes row A
COMMIT;                                              -- Txn B writes row B
Two correct transactions. One broken rule. rule: at least one doctor must stay on call Txn A Txn B COUNT = 2 ✓ A → off call COMMIT COUNT = 2 ✓ B → off call COMMIT 0 doctors on call the rule said this could never happen they wrote different rows, so no lock or snapshot conflict ever fired

Here's why a snapshot doesn't save you. Snapshot isolation watches for two transactions writing the same row. These two never do. The damage comes from the combination, and the only level that reasons about combinations is SERIALIZABLE.

Where you'll actually see this. A joint account overdrawn by two withdrawals that each looked affordable. Two approvals that together bust a budget. Two claims on the last unit of stock tracked in a separate row. Two bookings for overlapping time ranges. Any rule of the form "the total across these rows must stay under X" is a write skew waiting to happen.

Five bugs, four settings

LevelDirty readNon-repeatablePhantomLost updateWrite skew
READ UNCOMMITTED❌❌❌❌❌
READ COMMITTED (your default, probably)✅❌❌❌❌
REPEATABLE READ (snapshot)✅✅engine-dependent✅❌
SERIALIZABLE✅✅✅✅✅

✅ = can't happen · ❌ = allowed to happen to you

Look at the READ COMMITTED row. That's where most production systems are sitting right now, and that level permits four of the five bugs. Nobody chose that. It's the default, and database vendors tune defaults for throughput, not for your ledger.

Check yours right now. SHOW transaction_isolation; in Postgres. SELECT @@transaction_isolation; in MySQL. Then read that row of the table as a list of bugs your code is currently exposed to.

The trap: the names lie

The same words in your SQL mean different things on different databases. This one catches teams during migrations.

Same SQL. Four different promises. SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; PostgreSQL you get: snapshot ✓ phantoms blocked ✗ write skew allowed SERIALIZABLE here is the real thing MySQL this is the default SELECT → snapshot UPDATE → latest row (not your snapshot!) ✗ write skew allowed Oracle this level doesn't exist at all its “SERIALIZABLE” is really a snapshot ✗ top level still allows write skew SQL Server lock-based by default readers block writers SNAPSHOT is a separate level one flag flips your whole app to MVCC Never carry an isolation assumption across a migration.

The fixes, cheapest first

Which fix do you actually need? two writers, one rule Can a constraint say it? UNIQUE · CHECK · EXCLUDE yes Add the constraint works at every level · nobody can bypass it no Fits in one statement? UPDATE … SET x = x − 1 WHERE x > 0 yes Atomic UPDATE no gap · no retries · any level no Rule spans many rows? a SUM · a COUNT · “at least one” · a cap no SELECT … FOR UPDATE or a version column lock in a consistent order! yes SERIALIZABLE the only thing that stops write skew …and you MUST retry on 40001 Just reading, across several queries? REPEATABLE READ — cheap, and fixes reports.

1. Close the gap

The bug exists because there's a gap between reading and writing. Delete the gap and the bug is gone — at every level, with no retries and no config:

-- Instead of: read stock, check it in the app, write it back
UPDATE inventory
SET stock = stock - 1
WHERE sku = 'HOODIE-M' AND stock > 0;
-- 0 rows affected ⇒ sold out. Handle it in the app.

The check and the write happen in one statement, holding a row lock the whole time. This one line fixes more real concurrency bugs than every isolation level combined.

2. Let the database enforce the rule

CREATE UNIQUE INDEX ON bookings (room, slot);   -- double-booking now impossible
ALTER TABLE inventory ADD CHECK (stock >= 0);   -- oversell fails loudly instead

Constraints work at every isolation level, cost nothing to read, and can't be bypassed by code someone writes next year.

3. Lock the row you're about to change

BEGIN;
SELECT stock FROM inventory WHERE sku = 'HOODIE-M' FOR UPDATE;  -- others wait here
-- ... application logic ...
UPDATE inventory SET stock = stock - 1 WHERE sku = 'HOODIE-M';
COMMIT;
If you take two locks, always take them in the same order — ascending primary key works. A transfer A→B locking A then B, running against a transfer B→A locking B then A, is a textbook deadlock. The database kills one of them, so you need retry logic here too.

4. Raise the level

For a report that must balance, one snapshot fixes it:

BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT SUM(total) FROM orders;        -- both queries see
SELECT SUM(qty) FROM order_items;     -- the exact same instant
COMMIT;

For a rule spanning rows, SERIALIZABLE is the only option — and it needs a retry wrapper:

async function withSerializable(fn, maxAttempts = 5) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      await db.query("BEGIN ISOLATION LEVEL SERIALIZABLE");
      const result = await fn();
      await db.query("COMMIT");
      return result;
    } catch (err) {
      await db.query("ROLLBACK");
      // 40001 = conflict · 40P01 = deadlock
      const retryable = err.code === "40001" || err.code === "40P01";
      if (!retryable || attempt === maxAttempts) throw err;
      await sleep(2 ** attempt * 10 + Math.random() * 50);   // backoff + jitter
    }
  }
}
SERIALIZABLE without retries isn't safer — it's a new outage. Postgres doesn't make conflicting transactions wait. It lets both run, then aborts one at commit time. If your app turns that abort into a 500, you've traded a rare silent bug for frequent loud failures. You get them at exactly the traffic where you can least afford them. Also: the code inside must be safe to run twice, so no sending emails or charging cards in there.

Four traps

  • Your ORM already opened a transaction. Most frameworks wrap each request at the default level. So find() → change → save() is running at READ COMMITTED whether you thought about it or not — a lost update waiting for traffic.
  • Isolation sticks to the connection. SET SESSION ... leaks into whatever request takes that pooled connection next. Set it per transaction (BEGIN ISOLATION LEVEL ...) instead.
  • None of this crosses a replica or a service. Read from a replica and you get lag on top of everything here (read-your-writes). Split the rule across two services and SERIALIZABLE in each guarantees nothing about the pair (life beyond distributed transactions).
  • Your tests will never find these. Every bug here needs two transactions interleaved at a precise instant. A single-threaded test suite produces exactly zero of them. Encode the rule as a constraint, or write a test that runs two real concurrent transactions.
Raising the level
  • Correctness without hand-written locks everywhere.
  • REPEATABLE READ makes multi-query reports consistent — nearly free.
  • SERIALIZABLE is the only thing that stops write skew.
  • Protects code paths written later by people who never read the rules.
What it costs
  • Conflicts (40001) you must retry in the app.
  • More aborts and deadlocks as load rises — worst at peak.
  • Long snapshots cause bloat and vacuum pressure.
  • Throughput drops as hot rows get hotter.
  • Zero protection across replicas or services.

The takeaway

  1. Find out what level you're on. One query. If it's READ COMMITTED — and it probably is — the level allows four of the five bugs above to happen to your users. None of them appears as an error.
  2. Write skew is the gap that matters. Everything below SERIALIZABLE permits it, including Oracle's level literally named SERIALIZABLE. Any rule of the form "the total across these rows must stay under X" is exposed, and it fails silently.
  3. Close the gap before you widen the guarantee. An atomic UPDATE, a UNIQUE index, or a CHECK fixes most of this outright, at any level, with no retries. Save SERIALIZABLE for rules that can't be written any other way — and ship the retry wrapper in the same commit.

Isolation is a negotiation your database offers you, and staying silent is still an answer. Someone chose your default. It just wasn't you, and they'd never seen your data.

Further reading. Berenson, Bernstein, Gray, Melton, O'Neil & O'Neil, "A Critique of ANSI SQL Isolation Levels" (1995). This paper named snapshot isolation and showed that the standard doesn't say what people think. Martin Kleppmann, Designing Data-Intensive Applications, chapter 7 — the clearest treatment of write skew in print. Ports & Grittner, "Serializable Snapshot Isolation in PostgreSQL" (2012). And the PostgreSQL "Transaction Isolation" and MySQL InnoDB "Consistent Nonlocking Reads" docs, worth reading side by side to see how far the same words diverge. Related here: hard vs soft delete under concurrency and read-your-writes and replica lag.