Isolation Levels: Read Committed vs Serializable
Interview Question: "Which isolation level would you use in a high-traffic payment system, and why? What are the trade-offs between Read Committed and Serializable, and which anomalies does each one actually prevent?"
An isolation level is not a performance setting. It is a contract about which anomalies
your transaction is allowed to observe β and the common advice, "use Read Committed with
SELECT β¦ FOR UPDATE because Serializable is too slow", gets both halves wrong. It
misstates what Serializable does, and it recommends a substitute that does not cover the
anomaly Serializable exists for.
The anomalies, in the order they biteβ
T1 reads T2's uncommitted rowT1 reads a row twice, gets two valuesT1 runs the same WHERE twiceT1 and T2 read the same rows, write different onesThe first three are the textbook three and every database discussion covers them. Write skew is the one that actually costs money, and it is the one almost every answer to this question omits.
What PostgreSQL actually promisesβ
The SQL standard defines the levels by which anomalies they permit. PostgreSQL is stricter than the standard at every level, which matters because the textbook table is not the table you are working with.
| Dirty read | Non-repeatable read | Phantom read | Write skew | Lost update | |
|---|---|---|---|---|---|
| Read Uncommitted | neverΒΉ | possible | possible | possible | possible |
| Read Committed (default) | never | possible | possible | possible | possibleΒ² |
| Repeatable Read | never | never | neverΒ³ | possible | neverβ΄ |
| Serializable | never | never | never | never | never |
ΒΉ PostgreSQL has no Read Uncommitted β asking for it gives you Read Committed. There is no mechanism to read an uncommitted row.
Β² A plain read-then-write loses updates. A guarded UPDATE β¦ WHERE balance >= 100 does not,
because UPDATE re-reads the row under a row lock and re-evaluates the predicate.
Β³ Stricter than the standard. PostgreSQL's Repeatable Read is true snapshot isolation: the whole transaction sees one frozen snapshot, so new rows cannot appear.
β΄ First updater wins; the loser gets could not serialize access due to concurrent update
and must retry.
So the real choice in PostgreSQL is narrower than the four-row table suggests. Read Committed and Serializable differ in exactly one practically important way: write skew.
Write skew: the anomaly FOR UPDATE does not fixβ
The standard example. A hospital requires at least one doctor on call at all times. Two doctors are on call; both are tired; both click "go off call" simultaneously.
SELECT count(*) FROM doctors WHERE on_callUPDATE doctors SET on_call = false WHERE id = 'alice'COMMITSELECT count(*) FROM doctors WHERE on_callUPDATE doctors SET on_call = false WHERE id = 'bob'COMMITNow look at why locking does not save you. Each transaction writes a different row.
Alice locks Alice; Bob locks Bob. The locks never collide, so there is nothing to wait for
and nothing to detect. Adding FOR UPDATE to step 1 locks the rows each transaction
read β and at the moment they read, both rows exist and both are on call, so both locks
are granted and both transactions proceed exactly as before.
This is the shape that makes write skew different from a lost update. A lost update is two transactions fighting over one row, and a row lock resolves it. Write skew is two transactions agreeing about a set of rows and then each changing a different member of it. There is no single row to lock.
FOR UPDATE can prevent write skew, but only if you lock something both transactions
must touch β a parent row, a shift record, a per-ward lock row. That is the
"materialise the conflict" technique, and it works by inventing a shared row so the
problem becomes a lost update. It is a real option, and it is a manual, easy-to-forget
one: every new code path has to remember to take the same lock.
Serializable is optimistic, not serialβ
The widespread claim is that Serializable makes transactions run one at a time β "the bank manager freezes the building" β and that it is therefore unusable under load. That describes two-phase locking, which is how some databases implement it. It is not how PostgreSQL implements it.
PostgreSQL uses Serialisable Snapshot Isolation (SSI). Transactions run concurrently,
at full speed, taking no additional blocking locks. The engine tracks read/write
dependencies between them and, when it spots a pattern that could produce a result no
serial order could have produced, it aborts one of them with
ERROR: could not serialize access due to read/write dependencies among transactions
(SQLSTATE 40001).
readers block writers, writers block readersthroughput falls under loadno extra blocking at allaborts on detected conflict, SQLSTATE 40001So the honest cost of Serializable is not latency. It is that every transaction needs a retry loop, because any of them can fail at commit through no fault of its own β plus some bookkeeping memory for predicate locks, and a higher abort rate on genuinely contended rows. Declining it for that reason is entirely reasonable. Declining it because "serialisable means serial" means declining it in the cases where it would have been both correct and cheap.
So which do you useβ
Not one answer for the whole database. The level is per transaction, and the question to ask is what shape is the invariant I am protecting?
Read Committed + guarded UPDATEthe WHERE clause is the lockSerializable + retry loopcatch 40001, retry the whole transactionA payment system is mostly the left-hand column, which is why Read Committed is the right default for it β but "mostly" is not "entirely". A portfolio-level margin rule that reads many positions to authorise one write is a cross-row invariant, and no amount of row locking makes it safe.
For the mechanics of the locks themselves β FOR UPDATE, NOWAIT, SKIP LOCKED, version
columns and retry storms β see
Optimistic vs Pessimistic Locking. This page is
about which anomalies the level permits; that one is about the tools you reach for inside a
level.
Code: reproducing write skew in two psql sessionsβ
CREATE TABLE doctors (id TEXT PRIMARY KEY, on_call BOOLEAN NOT NULL);
INSERT INTO doctors VALUES ('alice', true), ('bob', true);
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call; -- 2
UPDATE doctors SET on_call = false WHERE id = 'alice';
-- do not commit yet
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call; -- 2
UPDATE doctors SET on_call = false WHERE id = 'bob';
COMMIT; -- succeeds
COMMIT; -- also succeeds
SELECT count(*) FROM doctors WHERE on_call; -- 0 β the invariant is broken
Re-run the whole thing with BEGIN ISOLATION LEVEL SERIALIZABLE and the second COMMIT
fails with SQLSTATE 40001 instead. Nothing blocked, nothing waited β one transaction was
simply told to try again.
Say this in the interviewβ
- Isolation levels are a contract about observable anomalies, not a speed dial.
- In PostgreSQL the practical gap between Read Committed and Serializable is write skew, because Repeatable Read already gives you snapshot isolation and therefore no phantom reads.
SELECT β¦ FOR UPDATEfixes lost updates, not write skew β two transactions writing different rows never contend for a lock.- PostgreSQL's Serializable is optimistic (SSI): no extra blocking, aborts on conflict, and the real cost is a retry loop rather than latency.
- Choose per transaction by the shape of the invariant: single-row predicates go in a
guarded
UPDATEunder Read Committed; cross-row invariants want Serializable.