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

Isolation levels, and what each one lets through

Stop treating isolation levels as a safety dial and start treating them as a list of anomalies you have agreed to tolerate, so surprises stop being surprises.

8 min read Updated October 5, 2026 Last verified August 15, 2026
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

Two cooks can work at once, but a strict kitchen rule requires the finished result to match some order of cooking separately. It may make one cook restart when their combined choices cannot meet that rule. Looser rules permit specific kinds of interference.

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. The MVCC snapshot example shows why PostgreSQL’s Read Committed permits the second value while Repeatable Read keeps the first.

Try it: when does the second read change?

Illustrative PostgreSQL 18 example. Session A begins a transaction and reads an account balance of 100. Session B then updates it to 120 and commits. Session A makes a second ordinary SELECT.

  1. Session A first read: 100. This SELECT uses a snapshot taken when the statement starts.
  2. Session B commits its update: 120.
  3. Session A second read: 120.

Read Committed: the second SELECT gets a new snapshot after B commits, so it sees 120.

This models two ordinary reads of one row and no writes by A. Locking reads, concurrent updates, DDL and retries have other rules. It does not run a database. Check PostgreSQL's isolation rules.

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 a concrete lost-update schedule under PostgreSQL Read Committed: read a value, compute the new one in the application, then write that literal back. Run the labelled statements in two sessions in the shown order:

two sessions
-- PostgreSQL READ COMMITTED: two sessions withdrawing 10.
-- 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.

Each literal UPDATE succeeds, but the caller used a stale value. A controlled two-session test can reproduce this even at low traffic. PostgreSQL Repeatable Read instead rejects the second concurrent update in this schedule; engine and isolation are part of the example, not interchangeable labels.

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
-- PostgreSQL READ COMMITTED: 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

For this single-row invariant under PostgreSQL Read Committed, compute from the current row inside UPDATE and inspect whether a row was actually affected. At snapshot-based levels a concurrent update can still abort, requiring a whole-transaction retry. This does not enforce arbitrary multi-row rules or include an external service call.

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 a required step on the persistence roadmap. Its transaction and lock vocabulary prepares you for the final chapter on deadlocks. Practise a two-transaction timeline before continuing: which result surprises you, and which guarantee would prevent it on your database?

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”.

Ordinary row locks can still deadlock at these levels. PostgreSQL’s serializable predicate locks track dependencies without blocking; they are not themselves a deadlock source. See a blocking-lock 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 concurrent inserts, deletes or predicate-changing updates altered which rows match the query; an update can also change an existing value. 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?

Check overlapping transactions, including ones that committed before the failure was reported. PostgreSQL may conservatively detect dependencies, and coarser predicate locks can increase false-positive conflicts; that does not mean a lack of concurrent activity alone causes an error. Preserve the actual SQLSTATE and diagnostics, retry the complete transaction when appropriate, and investigate workload and lock granularity before changing settings.

The library Browse all articles