Transaction isolation is taught as a grid: four levels down the side, some anomalies across the top, a scattering of “yes” and “no” in the cells. Nobody remembers it, because it is memorised forwards — level to guarantee — when it makes sense backwards. Transaction isolation levels become clear the moment you understand them through the specific bug each one lets through. Once you can name the anomaly, the level that prevents it is obvious.
Why the levels exist at all
Isolation is the “I” in ACID, and it answers one question: when many transactions run at the same time, how much can they see of each other’s unfinished work? Perfect isolation would mean every transaction runs as if it were completely alone, which is easy to reason about and slow, because it forbids most concurrency. So databases offer a dial. Weaker isolation permits more concurrency and more anomalies; stronger isolation prevents the anomalies and permits less concurrency. The levels are points on that dial, and choosing one is choosing which bugs you are willing to tolerate for which amount of throughput.
That framing is the key. You do not pick an isolation level by its name; you pick it by deciding which anomalies your data can survive. So the useful thing to learn is the anomalies.
Dirty read: seeing uncommitted work
The first and worst anomaly is the dirty read: one transaction reads data another transaction has written but not yet committed. If the writing transaction then rolls back, the reader acted on data that never officially existed — a value that was un-happened.
Imagine transaction A deducts money from an account but has not committed. Transaction B reads the new, lower balance and makes a decision on it. Then A rolls back, restoring the original balance. B acted on a number that never truly existed. This is almost always unacceptable, which is why Read Committed — the level that forbids dirty reads by only ever showing committed data — is the floor most databases use by default. In Postgres you essentially cannot have a dirty read; the weakest level it offers already prevents it.
Non-repeatable read: the value changes under you
The next anomaly is subtler. Within a single transaction, you read a row, do some work, read the same row again — and it has a different value, because another transaction committed a change in between. The same query, twice, in one transaction, gave two answers.
Whether this is a problem depends entirely on what you are doing. For a transaction that reads a row once and moves on, it is irrelevant. For a transaction that reads a value, makes a decision, and reads it again expecting consistency — a report that must be internally coherent, a calculation spanning several reads — it is a real bug. Preventing it requires Repeatable Read, which guarantees that every read within a transaction sees the same snapshot of the data, as of the moment the transaction began. The database freezes your view of the world for the duration.
Phantom read: the set changes, not the row
A phantom is the set-level cousin of the non-repeatable read. You run a query — “all orders over $100” — get ten rows, do some work, run the same query again, and now there are eleven, because another transaction inserted a matching row and committed. No individual row changed under you; the membership of the set changed.
This matters for any logic that assumes a set is stable across a transaction — aggregations, “check then act” patterns over a range, constraints you are enforcing in application code. Preventing phantoms is what Serializable, the strongest level, guarantees: transactions execute as if they had run one at a time in some sequence, so no set can shift beneath you. It is the safest and the most restrictive, and it can cause transactions to be aborted and retried when the database detects that true serial ordering was impossible.
The lost update: the one that silently corrupts data
The most dangerous anomaly in practice is not in the classic textbook trio, and it is the one that quietly corrupts real data. Two transactions read the same value, each modifies it based on what they read, and each writes it back. The second write overwrites the first, and one update is simply gone — silently, with no error.
-- Both transactions run this concurrently, inventory starts at 10:
BEGIN;
SELECT quantity FROM inventory WHERE id = 1; -- both read 10
-- both compute 10 - 1 = 9 in application code
UPDATE inventory SET quantity = 9 WHERE id = 1; -- second write clobbers first
COMMIT;
-- Two items sold, but quantity is 9, not 8. One sale vanished.
This is the classic read-modify-write race, and it is everywhere: decrementing stock, incrementing a counter, adjusting a balance. Read Committed does not prevent it, so the default isolation level in most databases allows this bug — which is why it is so common. There are three good fixes. Do the update atomically in the database (SET quantity = quantity - 1) so there is no read-then-write gap. Take an explicit lock with SELECT ... FOR UPDATE so the second transaction waits. Or use optimistic locking with a version column, so the second write detects that the row changed and fails rather than clobbering. The atomic version is simplest when the operation allows it.
How Postgres actually delivers this: MVCC
It helps to understand the machinery underneath, because it explains behaviour that otherwise seems surprising — especially why readers and writers so rarely block each other in Postgres. The mechanism is multi-version concurrency control, and its core idea is simple: rather than overwriting a row in place, an update writes a new version of the row and leaves the old one in place until no transaction still needs it.
This means every transaction effectively sees a consistent snapshot of the database as of a particular moment, assembled from whichever row versions were committed at that point. A reader does not need a lock, because it is reading versions that cannot change out from under it — a concurrent writer creates a new version without disturbing the old one the reader is looking at. This is why, in Postgres, readers never block writers and writers never block readers, which contrasts sharply with lock-based databases where a long read can stall writes. Understanding this dissolves the mystery of why a big analytical query does not freeze your writes.
MVCC also reframes the isolation levels as choices about which snapshot a transaction reads. Read Committed takes a fresh snapshot at the start of each statement, which is exactly why a value can change between two reads in the same transaction — each read sees the latest committed version. Repeatable Read takes one snapshot at the start of the transaction and reads from it throughout, which is why its reads are stable. The anomalies from earlier are not arbitrary; they fall directly out of when the snapshot is taken.
The one operational consequence worth carrying away is that those old row versions — called dead tuples — must eventually be cleaned up, which is the job of Postgres’s autovacuum. On a table with heavy updates, dead tuples accumulate as bloat, and if autovacuum cannot keep pace, tables and indexes swell and queries slow down. So MVCC’s gift of lock-free reads comes with a maintenance obligation: keep an eye on bloat and vacuum health, which is part of the picture in our database observability guide. The concurrency you enjoy is paid for by the cleanup you have to stay on top of.
Choosing a level, and living with the consequences
The practical advice is to stay at the default — Read Committed in Postgres — for the overwhelming majority of work, and to reach for a stronger level only for the specific transactions that genuinely need it. A financial calculation that must see a consistent snapshot wants Repeatable Read; a complex invariant that must hold across a set wants Serializable. Raising the level globally “to be safe” trades away a great deal of concurrency and invites more conflicts than most applications can afford.
Two consequences come with the stronger levels, and ignoring them causes outages. First, higher isolation means more transactions get aborted when the database cannot maintain the guarantee, so any code using Repeatable Read or Serializable must be prepared to catch a serialization failure and retry the whole transaction — and that retry needs jitter and a cap, exactly as in our retry storms guide, or a burst of conflicts becomes a retry storm. Second, explicit locks like FOR UPDATE introduce the possibility of deadlock, where two transactions each wait for a lock the other holds; the database detects it and kills one, which again must be retried.
The mental model to keep is the one we started with: do not memorise which level prevents which anomaly. Understand the anomalies — dirty read, non-repeatable read, phantom, and especially the lost update — and the correct level for a given transaction becomes obvious. Most of the time the default is right and the real fix for your worst concurrency bug is an atomic write or an explicit lock, not a higher isolation level at all.