Skip to the content
Software Made Clear Diagrams that show the mechanism About

Isolation levels, and what each one lets through

ANSWER

Pick a level by naming the anomaly that would actually corrupt your data, then take the cheapest one that forbids it. The levels are not a dial from less safe to more safe — each is a list of things the database is still allowed to let through.

IN PLAIN TERMS

A database shares itself out the same way a busy kitchen does. The strictest rule lets one person cook at a time: nothing can go wrong, and nothing gets done. Looser rules let several people in and just list what might happen — someone takes the last egg you were counting on, or a shelf changes while you are reading it.

You have four level names, a grid of ticks and crosses, and nothing in it that tells you which row to pick. The grid is not a difficulty setting you slide towards safe until performance complains. Each column is a contract, and what a contract lists is what the database is still allowed to do to you. So read it backwards: name the thing that would corrupt your data, find the levels that forbid it, take the cheapest of those.

The anomalies, named#

Every one of these is two sessions and four statements. Written out that way they stop being vocabulary.

Dirty read. T1 updates account 5088 to a balance of 40 and does not commit. T2 reads the row and sees 40. T1 rolls back. T2 is now holding a number that no committed state of the database ever contained — and the value was not wrong when it was read, it simply stopped existing.

Non-repeatable read. T1 reads account 5088 and gets 100. T2 updates that row to 40 and commits. T1 reads the same row again and gets 40. One transaction, one row, two answers — so any check T1 made on the first value is worthless by the time it acts on it.

Phantom read. T1 runs SELECT count(*) FROM orders WHERE tenant = 7 and gets 12. T2 inserts an order for tenant 7 and commits. T1 runs the identical query and gets 13. Nothing T1 already read has changed; what changed is the set of rows that match.

Lost update. Two sessions each withdraw 10 from the same account. T1 reads a balance of 100; T2 reads 100 too. T1 writes 90 and commits; T2 writes 90 and commits. Two withdrawals happened and one of them is gone — not overwritten by a later decision, just gone, because T2 computed from a number that had already been superseded.

Write skew. Two rows and a rule that spans both. At least one doctor must stay on call. T1 checks the count, sees two, and takes doctor A off. T2 checks the same count, in its own snapshot also sees two, and takes doctor B off. Both checks were true when they ran. Neither transaction touched a row the other wrote, so there is no collision to detect — and after both commit the rule is broken.

Now the grid, with the standard’s own answers in it.

AnomalyRead uncommittedRead committedRepeatable readSerializable
Dirty readPermittedForbiddenForbiddenForbidden
Non-repeatable readPermittedPermittedForbiddenForbidden
Phantom readPermittedPermittedPermittedForbidden
Lost updateNot in the listNot in the listNot in the listForbidden
Write skewNot in the listNot in the listNot in the listForbidden

The last two rows are the interesting ones. The phenomena the levels are defined against are dirty read, non-repeatable read, phantom read and serialization anomaly — lost update and write skew are not among them by name. Serializable forbids both anyway, because it promises a result matching some order of running the transactions one at a time. Below it, whether you get caught is a question about your engine, not about the standard.

What the standard says, and what your database does#

The levels are defined by which phenomena must not occur at each one. That is a floor, not a specification of behaviour, and PostgreSQL’s documentation says so directly: the standard specifies which anomalies must not occur at certain levels, so higher guarantees are acceptable. Two engines can honour the same column of that grid and still behave differently under the same level name — and the differences are not exotic. They are the defaults you are running on now.

PostgreSQL has four level names and three behaviours. Its documentation states that you can request any of the four standard levels but that internally only three distinct levels are implemented: read uncommitted behaves like read committed. So the top row of that grid has no PostgreSQL column — you cannot ask for dirty reads there, and asking gets you something stricter without an error. The same page records that its repeatable read implementation does not allow phantom reads either. Two of the five rows are wrong for PostgreSQL before you write a line of code.

MySQL’s InnoDB gives one level two behaviours. Its default is repeatable read, and under it a plain SELECT reads a snapshot established by the transaction’s first read, while a locking read — SELECT … FOR UPDATE or FOR SHARE — uses the most recent state of the database instead. The documentation is explicit that these are two different table states, that they are generally inconsistent with each other, and that mixing locking and non-locking statements in one repeatable read transaction is not recommended, because what you wanted in such a case is usually serializable. The practical shape of that: the same row can hand you two different values inside one transaction, depending on which kind of statement asked for it.

And before reaching for read committed as a performance fix on that engine, know what it costs. Under read committed InnoDB disables gap locking except for foreign-key and duplicate-key checking, which its documentation notes may permit phantom rows — less locking, bought with a wider set of anomalies.

Write skew is the claim to test all of this against, and PostgreSQL’s documentation works one through. A table holds rows in two classes. Transaction A sums the values of class 1 and inserts the total as a new row in class 2; concurrently, transaction B sums class 2 and inserts its total as a new row in class 1. At repeatable read both are allowed to commit. At serializable one commits and the other is rolled back with could not serialize access due to read/write dependencies among transactions, because there is no order of running the two one after the other that produces what they produced together.

Which gives the rule the rest of this page rests on. Do not pick a level by how safe it sounds. Name the anomaly that would corrupt your data — the rule that must hold, and the interleaving that breaks it — then take the cheapest level that forbids it on the engine you are running, confirmed against that engine’s documentation rather than against a grid.

The read-modify-write that passes review#

Here is the bug this page exists for: the most common correctness fault in transactional code, and it passes every review. Read a value, compute the new one in the application, write it back:

two sessions
-- Two sessions withdrawing 10 from the same account. Each statement runs
-- after the one above it. Every statement here is individually correct.

-- T1
BEGIN;
SELECT balance FROM accounts WHERE id = 5088;
-- 100

-- T2
BEGIN;
SELECT balance FROM accounts WHERE id = 5088;
-- 100 as well

-- T1 · the application computes 100 - 10 and writes the result
UPDATE accounts SET balance = 90 WHERE id = 5088;
COMMIT;

-- T2 · the application computed 100 - 10 too, from the value it read
UPDATE accounts SET balance = 90 WHERE id = 5088;
COMMIT;

-- Two withdrawals of 10 against a balance of 100. The row says 90.

Nothing there is a mistake anyone would circle in a diff. The SELECT is correct, the arithmetic is correct, and the UPDATE writes exactly the value the application computed. The fault is in the gap between the second and third statements, where the number T2 is holding quietly stops being true — and a gap is not a line of code. It will not reproduce, either: one request at a time, the sessions never overlap. It needs two in the same few milliseconds, so it arrives under load, in production, as money that does not add up and no error in the logs.

The first fix removes the gap rather than defending it. If the new value is a function of the old one, say that in SQL and let the database read and write in one operation:

one statement
-- The same work as one statement. The database reads the current row and
-- writes the new value in the same operation, under the lock the UPDATE takes.

BEGIN;
UPDATE accounts SET balance = balance - 10
 WHERE id = 5088 AND balance >= 10;
COMMIT;

-- Run twice concurrently, the second UPDATE waits for the first to end,
-- then subtracts from 90 rather than from the 100 it would have read earlier.
-- The row says 80. The guard in the WHERE clause is evaluated against the
-- same current row, so it cannot be satisfied by a value that has since moved.

The difference between the two blocks is balance = balance - 10 against balance = 90. That is the whole lesson: the second carries a number decided elsewhere, at a time that has passed.

Three fixes, and each one costs something:

01

Compute inside the statement

Cheapest, and it needs no level change. The cost is reach: it works only when the new value is a function of the current row that SQL can express. A new stock count from the old one, yes; a value that depends on a call to an external service, no.

SET balance = balance - :amount

02

Lock the row when you read it

Take the lock at read time and the second session waits instead of reading a value about to expire. The cost is real serialisation: the row is held until the transaction ends, so every other writer queues behind whatever else your transaction does — and several such locks make lock ordering your problem again.

SELECT … FOR UPDATE

03

Move to a level that aborts instead

On PostgreSQL, a repeatable read transaction that tries to modify a row another transaction changed after it began is rolled back with could not serialize access due to concurrent update. Read committed does not: it lets the second write land on the current row, which is why the update above is lost rather than rejected. The cost: the failure is now yours to handle, and the documentation says applications using these levels must be prepared to retry.

SQLSTATE 40001 → retry

The third one carries a condition that is easy to skip past. Retrying means running the transaction from the beginning again, and if the work it does is not safe to repeat — an order, an email, a message onto a queue — the retry turns one correctness bug into another. Idempotency keys and safe retries is about making an operation safe to repeat.

Isolation is marked optional on the persistence roadmap rather than required. The nodes that are required run from how a row sits on a page through to deadlocks.

IF YOU REMEMBER ONE THING

A level is not a promise about how careful the database will be. It is a list of what remains possible — so the useful question is never “is this level safe enough”, it is “which anomaly am I buying protection from, and does this engine actually sell it under this name”.

Every level above read-committed buys its guarantee with locks or with versions, and both can end up waiting on each other. What that looks like when it happens is a deadlock, drawn.

Questions people also ask

5 QUESTIONS
Which isolation level should I use by default?

The one your engine already gives you, until you can name the anomaly that would break a specific rule in your data. PostgreSQL's default_transaction_isolation is documented as read committed; InnoDB's default is repeatable read. Then check the thing most people assume the default covers and it does not: a SELECT, a computation in application code and an UPDATE writing a literal back can still lose one of two concurrent updates under both of those defaults. Raising the level for that one transaction is a legitimate fix, but so is rewriting the statement, and the statement is cheaper.

Is serializable always slower?

Not always, and PostgreSQL's own documentation argues the opposite for some workloads. Its serializable level uses predicate locks, which show up in pg_locks as SIReadLock, do not block, and cannot take part in a deadlock — measured against the blocking and disk access that SELECT FOR UPDATE or a table lock would have cost to get the same guarantee, the documentation calls serializable the best performance choice for some environments. What it does cost is retries, and the documented tips are about keeping them rare: short transactions, READ ONLY where it applies, no connections left idle in transaction, and index scans rather than sequential ones, because a sequential scan always takes a relation-level predicate lock.

What is the difference between a phantom read and a non-repeatable read?

Whether the row existed. A non-repeatable read is the same row read twice with two different values, because a concurrent transaction updated that row and committed. A phantom read is the same query run twice returning a different set of rows, because a concurrent transaction inserted or deleted rows that match the WHERE clause — the rows you already read are untouched. That difference decides what it takes to prevent them: the first needs the engine to pin rows that exist, the second needs it to account for rows that do not exist yet — either by refusing to see anything committed after the transaction started, which is how PostgreSQL's repeatable read ends up forbidding phantoms, or by locking the gaps where such a row would go, which is what InnoDB's gap locks are for.

Does my ORM set an isolation level for me?

Some do, and one of them changes the answer out from under you. Django documents that it defaults to read committed on PostgreSQL, matching the server, and that on MySQL it also defaults to read committed rather than MySQL's own default of repeatable read, with an isolation_level entry in the OPTIONS part of the database configuration to change it. So the level your code actually runs at is not necessarily the one the server was configured with. Read it from the session rather than inferring it: SHOW transaction_isolation on PostgreSQL, SELECT @@SESSION.transaction_isolation on MySQL.

Why did my transaction fail with a serialization error when nothing else was running?

Something else was running, or the engine could not prove that nothing was. PostgreSQL documents both halves. It says it is very hard to predict exactly which transactions might contribute to the read/write dependencies and need to be rolled back, so the transaction you see fail is often not the one that caused the conflict. And it says serializable does not always prevent errors being raised that would not occur in true serial execution — when the predicate lock table runs short of memory, finer locks are combined into a relation-level one and the rate of serialization failures rises. The fix for both is the same: retry the whole transaction, and raise max_pred_locks_per_transaction if the second shape is what you are hitting.