On this page
- What a transaction promises, and why the "I" is slippery
- The anomaly catalogue, one timeline each
- The four SQL-standard levels
- What Postgres and MySQL actually give you (members only)
- Fixing the races, cheapest first (members only)
- What happens in the real world (members only)
- 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
COMMITreturns, 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.
Dirty read: Session B trusts a change that is later rolled back
-- 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.
Non-repeatable read: same row, same transaction, two answers
-- 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.
Phantom read: a new row starts matching the search
-- 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.
Lost update: two read-modify-write cycles, one write vanishes
-- 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.
Write skew: two safe-looking inserts, one seat sold twice
-- 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.
| Level | Dirty read | Non-repeatable read | Phantom read | Lost update, write skew |
|---|---|---|---|---|
| Read Uncommitted | Possible | Possible | Possible | Not mentioned by the standard |
| Read Committed | Prevented | Possible | Possible | Not mentioned by the standard |
| Repeatable Read | Prevented | Prevented | Possible | Not mentioned by the standard |
| Serializable | Prevented | Prevented | Prevented | Prevented (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