System design / Databases

Transaction Isolation Levels Explained: Dirty Reads, Lost Updates, Write Skew and What Postgres and MySQL Really Do

Every transaction anomaly (dirty read, non-repeatable read, phantom, lost update, write skew) as a two-session timeline, what Read Committed, Repeatable Read and Serializable really mean in PostgreSQL and MySQL InnoDB, and how to fix double booking and overselling with atomic updates, locks, version columns, constraints and retries.

On this page
  1. What a transaction promises, and why the "I" is slippery
  2. The anomaly catalogue, one timeline each
  3. The four SQL-standard levels
  4. What Postgres and MySQL actually give you (members only)
  5. Fixing the races, cheapest first (members only)
  6. What happens in the real world (members only)
  7. How to say it in the interview (members only)

It is 10:00:00 and tickets for a sold-out band go on sale. Seat A12 in the front row is still free. Two fans tap Buy at the same moment, two requests land on two app servers, and both check the database: "Is A12 taken?" Both hear "no". Both insert a booking. Both get a confirmation email. On the night, two people walk up to one seat. Nothing crashed, no query failed, and every line of code did what it said. The bug lives in the gap between two transactions, and that gap is what isolation levels are about.

Where this fits: Part of the Databases under the hood series. The series hub gives you the one-table summary of isolation; this blog takes every anomaly apart with a timeline, then checks what PostgreSQL and MySQL actually do.

Before you start: Nothing required. ACID is covered in the Databases guide, and Indexes in practice helps with the InnoDB gap-lock section, but neither is a prerequisite.

After reading this you will be able to

  • Draw a two-session timeline for a dirty read, non-repeatable read, phantom, lost update and write skew.
  • Say which anomalies each SQL-standard isolation level is supposed to prevent, and why the standard's list is incomplete.
  • Explain what Read Committed, Repeatable Read and Serializable really do in PostgreSQL and MySQL InnoDB, with the error messages you will see.
  • Pick the right fix for a race: an atomic update, SELECT ... FOR UPDATE, a version column, a constraint or Serializable with retries.
  • Answer "how do you stop double booking?" in a system design interview without hiding behind "use Serializable".

For this blog, we'll stay inside one database: one PostgreSQL or MySQL primary with many concurrent connections. Replication lag, cross-shard transactions and MVCC internals get their own parts later.

01What a transaction promises, and why the "I" is slippery

Transaction Definition

A group of reads and writes that the database treats as one unit. It starts with BEGIN and ends with COMMIT (keep everything) or ROLLBACK (keep nothing). Booking a seat might be three statements: check the seat, insert the booking, decrement the tier's stock. A transaction makes them one decision.

You have probably met ACID before. Here it is in one line per letter:

  • Atomicity: all of the transaction happens, or none of it does.
  • Consistency: the database moves from one valid state to another, as far as your constraints describe "valid".
  • Isolation: transactions running at the same time don't see or trample each other's work, to the degree the isolation level promises.
  • Durability: once COMMIT returns, the data survives a crash.

Three of those are close to yes-or-no. Isolation is a dial, and the default setting is not the strongest one. That is why "we use transactions" does not mean "we have no race conditions".

Picture a concert hall box office before computers: one whiteboard of seats, several clerks on the phones. The safest rule is only one clerk at the whiteboard at a time. Nobody double-books, but callers wait. Faster rules let every clerk use the board at once: some read it live, some work from a photo of it, some stick a "mine" note on a seat first. Each rule is an isolation level, and each lets a different mistake slip through.

Isolation level Definition

The setting that decides how much one running transaction can see of, and be affected by, other running transactions. The strongest level, serializable, promises the result is the same as if the transactions had run one at a time in some order. Weaker levels allow named anomalies in exchange for less waiting.

The 30-second answer

"Isolation levels trade safety for concurrency. Read Committed stops dirty reads, Repeatable Read gives a stable view, Serializable behaves as if transactions ran one by one. The traps are lost updates and write skew, which the PostgreSQL and MySQL defaults don't stop, so I fix them with atomic updates, row locks, version checks, constraints, or Serializable with retries."

02The anomaly catalogue, one timeline each

An anomaly is a result you could never get if the transactions had run one after another. We'll use one small ticketing app for all of them (PostgreSQL syntax; MySQL needs small changes):

CREATE TABLE ticket_tiers (
  id      int PRIMARY KEY,
  show_id int NOT NULL,
  name    text,          -- 'Standing', 'Balcony'
  price   int,
  stock   int,           -- tickets left
  version int DEFAULT 0  -- used later for optimistic locking
);
CREATE TABLE bookings (
  id      bigserial PRIMARY KEY,
  show_id int, seat text, fan_id int
);

In every diagram below, Session A is the top row, Session B the bottom row, and the numbered badges give the order in which statements reach the database.

Dirty read: reading work that might be undone

A dirty read happens when a transaction reads data that another transaction has written but not committed. If that writer rolls back, the reader acted on a value that never officially existed.

The last standing ticket is in someone's checkout. Their transaction sets stock to 0, then the card is declined and it rolls back. In between, another fan loads the page.

Fig. 1

Dirty read: Session B trusts a change that is later rolled back

Dirty read: Session B trusts a change that is later rolled backSession A, a checkout, updates the stock of the last ticket from 1 to 0 but has not committed. Session B, another fan, reads the stock and sees 0. Session A then hits a payment failure and rolls back, so the stock is really 1, but Session B has already shown the fan a sold-out page.Session AcheckoutSession Banother fanUPDATE stock1 → 0, not committed1SELECT stocksees 02payment failsROLLBACK3shows sold outstock is really 14timeB acted on a value that never committed
What to notice. Step 2 reads a value from step 1 that is not committed. Step 3 rolls it back, so the "sold out" page in step 4 is based on nothing.
-- Session A                                   -- Session B (READ UNCOMMITTED)
BEGIN; UPDATE ticket_tiers SET stock = 0 WHERE id = 3;
                                               SELECT stock FROM ticket_tiers WHERE id = 3;  -- 0
ROLLBACK;  -- stock is 1 again

You rarely meet this one. The PostgreSQL docs say its Read Uncommitted behaves like Read Committed, so dirty reads never happen. MySQL InnoDB allows them only if you choose READ UNCOMMITTED.

Non-repeatable read: same row, two answers

A non-repeatable read happens when a transaction reads the same row twice and gets two different committed values, because someone committed a change in between. A receipt job reads the Standing price for the line item, then again for the total. In between, the promoter raises the price.

Fig. 2

Non-repeatable read: same row, same transaction, two answers

Non-repeatable read: same row, same transaction, two answersSession A builds a receipt and reads the ticket price, 80. Session B, the promoter, changes the price to 95 and commits. Session A reads the same row again and now sees 95, so its receipt mixes two prices.Session Areceipt jobSession BpromoterSELECT pricereturns 801UPDATE price80 → 952COMMITpromoter done3SELECT pricereturns 954timeA read one row twice and got two committed values
What to notice. Session A never saw anything uncommitted. It still printed a receipt with a line item of 80 and a total based on 95.
-- Session A (receipt)                         -- Session B (promoter)
BEGIN; SELECT price FROM ticket_tiers WHERE id = 3;  -- 80
                                               UPDATE ticket_tiers SET price = 95 WHERE id = 3; COMMIT;
SELECT price FROM ticket_tiers WHERE id = 3;   -- 95 under READ COMMITTED

Its cousin is read skew: you read two related rows at slightly different moments and see a combination that never existed. Read Standing, the promoter moves 50 tickets from Standing to Balcony, then read Balcony: your report counts those 50 twice.

Phantom read: a new row joins the party

A phantom read happens when you rerun a query with the same WHERE clause and a different set of rows comes back, because another transaction inserted or deleted a matching row. Our app limits each fan to four tickets per show, so it counts what the fan holds before adding one. Meanwhile the same fan buys from a laptop too.

Fig. 3

Phantom read: a new row starts matching the search

Phantom read: a new row starts matching the searchSession A counts the tickets held by fan 42 and gets 3. Session B inserts a new ticket for fan 42 and commits. Session A runs the same count again and gets 4: no existing row changed, a new one appeared.Session Alimit checkSession Bfan's laptopCOUNT ticketsfan 42 → 31INSERT ticketfor fan 422COMMITnew row is real3COUNT againfan 42 → 44timeSame WHERE clause, a different set of rows
What to notice. Session B did not touch any row that Session A had read. It inserted a brand new row that matches A's search, and A's second count changes.
-- Session A (limit check)                     -- Session B (fan's laptop)
BEGIN; SELECT count(*) FROM bookings WHERE show_id = 7 AND fan_id = 42;  -- 3
                                               INSERT INTO bookings (show_id, seat, fan_id) VALUES (7, 'C3', 42); COMMIT;
SELECT count(*) FROM bookings WHERE show_id = 7 AND fan_id = 42;          -- 4

Phantoms matter because row locks can't easily stop them. You can lock a row that exists, but locking "every row that might match" needs a range or predicate lock, which is much harder. That comes back in write skew.

Lost update: the classic overselling bug

A lost update happens when two transactions read a value, compute a new one in application code, and both write it back. The second write silently overwrites the first. This read-modify-write race is behind most "we sold more than we had" incidents. The Standing tier has 10 left; fan 1 buys 2 and fan 2 buys 3, at the same time.

Fig. 4

Lost update: two read-modify-write cycles, one write vanishes

Lost update: two read-modify-write cycles, one write vanishesThe standing tier has 10 tickets. Session A and Session B both read 10. A sells 2 and writes 8, then commits. B sells 3 and writes 7, then commits, overwriting A. Five tickets were sold but the counter says 7 are left.Session Afan 1 buys 2Session Bfan 2 buys 3SELECT stockreturns 101SELECT stockreturns 102SET stock = 8sold 2, COMMIT3SET stock = 7sold 3, COMMIT4time5 tickets sold, counter says 7 left: A's write is gone
What to notice. Both sessions computed from the same starting value of 10. The final write wins, so five tickets left the building but the counter only dropped by three.
-- both sessions, interleaved as in the diagram
SELECT stock FROM ticket_tiers WHERE id = 3;          -- 10 for both
UPDATE ticket_tiers SET stock = 8 WHERE id = 3;       -- A: 10 - 2, computed in the app
UPDATE ticket_tiers SET stock = 7 WHERE id = 3;       -- B: 10 - 3, overwrites A

The bug is in the shape of the code: arithmetic outside the database, a literal written back. The database had no idea 8 and 7 both came from 10.

Before you read on

Guess: if you wrap the code above in a transaction at MySQL's default level, Repeatable Read, is the lost update fixed? Hold your answer; we check it against real test results after the levels table.

Write skew: the double booking from the opening

Now the hard one. Write skew happens when two transactions read the same data, each decides it is safe, and each writes a different row. Nothing was overwritten, so nothing looks like a conflict, yet together they break a rule both checked. This is the seat A12 story: the rule is "one booking per seat", checked before inserting.

Fig. 5

Write skew: two safe-looking inserts, one seat sold twice

Write skew: two safe-looking inserts, one seat sold twiceSession A and Session B both check whether seat A12 has a booking and both see none. Each inserts its own booking row and commits. The two transactions never wrote the same row, yet the rule of one booking per seat is broken.Session Afan 7Session Bfan 9check A120 bookings: free1check A120 bookings: free2INSERT bookingfan 7, COMMIT3INSERT bookingfan 9, COMMIT4timeDifferent rows, no conflict detected, seat A12 sold twice
What to notice. The two inserts create two different rows. There is no shared row for the database to see a conflict on, so a database that only checks write-write conflicts lets both commit.
-- each session, at the same time
SELECT 1 FROM bookings WHERE show_id = 7 AND seat = 'A12';   -- no rows
INSERT INTO bookings (show_id, seat, fan_id) VALUES (7, 'A12', 7);  -- B inserts fan 9
COMMIT;

The per-fan limit is write skew too: both devices count 3, both insert, and the fan ends with five. The shape is always the same: check a condition, then write something that changes it, with the check and the write not protected together. When the check is "does a matching row exist?", there is no row yet to lock.

Many candidates call this a lost update

It isn't. In a lost update both write the same row, which many databases can detect. In write skew they write different rows, so write-write conflict checks see nothing. The fixes differ, so naming it right is a strong signal.

03The four SQL-standard levels

The SQL standard names four isolation levels. It defines the three weaker ones by which phenomena they must prevent, and it only defines three phenomena: dirty read, non-repeatable read and phantom read. Serializable is defined differently: the result must equal some one-at-a-time order of the transactions. The PostgreSQL docs add a fourth column, "serialization anomaly", for everything else that a serial order would forbid.

LevelDirty readNon-repeatable readPhantom readLost update, write skew
Read UncommittedPossiblePossiblePossibleNot mentioned by the standard
Read CommittedPreventedPossiblePossibleNot mentioned by the standard
Repeatable ReadPreventedPreventedPossibleNot mentioned by the standard
SerializablePreventedPreventedPreventedPrevented (serial order)

Look at the last column. The two anomalies that do the most real damage are not in the standard's list. The standard only sets a floor, and each database builds something different on top. Jepsen's write-up points to A Critique of ANSI SQL Isolation Levels by Berenson and colleagues, which formalised snapshot isolation partly because the standard's wording was ambiguous.

Snapshot isolation Definition

Each transaction reads from a frozen snapshot of the database taken when it starts, so it never sees other transactions' changes mid-flight. When two transactions write the same row, one of them is stopped. It is the "take a photo of the whiteboard" clerk, and it prevents dirty reads, non-repeatable reads and phantoms, but not write skew.

So the name tells you less than you think. The real question is: what does my database actually do at this level, for this anomaly?

Seen this before?

The Databases guide has the one-table version of this section. The timelines above are the reason behind each cell.

Members-only article

Keep reading with the Article Pass

You've read the free part. The other 4 sections of this guide, with every diagram and worked example, are for members. One Article Pass unlocks every members-only article on Engineering Atlas: the ones live today and every one I publish while your pass is active.

  • What Postgres and MySQL actually give you
  • Fixing the races, cheapest first
  • What happens in the real world
  • How to say it in the interview

From ₹99 · one-time payment, no auto-renewal · separate from course passes