Transaction isolation levels
Article.
Isolation levels decide which interleavings of concurrent transactions you are allowed to observe. The standard defines them by the anomalies they forbid; each database implements them differently, and PostgreSQL’s implementation, snapshots, explains every row of the table better than the table does.
Does REPEATABLE READ prevent a lost update? In PostgreSQL yes, with an error you must handle; in MySQL no. Every result below comes from a two-session psycopg 3 script against PostgreSQL 18.4. ACID as a whole is in Transactions and ACID; the cross-database catalogue of anomalies in Isolation anomalies across databases.
The levels in PostgreSQL
| Level | Snapshot | Still allows |
|---|---|---|
| READ UNCOMMITTED | same as READ COMMITTED | (no dirty reads in PG) |
| READ COMMITTED (default) | new one per statement | non-repeatable read, read skew, lost update, phantoms |
| REPEATABLE READ | one per transaction | write skew |
| SERIALIZABLE | one per transaction, plus SSI | nothing: equals some serial order |
BEGIN ISOLATION LEVEL SERIALIZABLE; -- or SET TRANSACTION ... as the first statementPostgreSQL’s REPEATABLE READ is stronger than the standard asks: snapshot isolation also removes phantoms. Its SERIALIZABLE exists for the one anomaly snapshots allow, write skew.
How a snapshot works
A snapshot is three numbers: the oldest transaction still running (xmin),
the next id to be assigned (xmax), and the list in progress between them.
Each row version carries the id that created it and the id that deleted it
(PostgreSQL architecture); a row is
visible if its creator committed before the snapshot and its deleter did not.
SELECT pg_current_snapshot(); -- 224397:224397: (xmin:xmax:in-progress)The snapshot is taken by the first statement, not by BEGIN:
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- another session commits bal = 555
SELECT bal ... -> 555 the first query took the snapshot
-- another session commits bal = 666
SELECT bal ... -> 555 the snapshot stays fixedREAD COMMITTED takes a fresh snapshot for every statement; REPEATABLE READ and SERIALIZABLE keep the first one until the end.
READ COMMITTED: what a fresh snapshot per statement allows
Non-repeatable reads and read skew
same row, same transaction: 100 then 50
transfer of 50 from account 1 to 2, read between the two statements:
reader saw 100 + 150 = 250 (true total 200)
same reader under REPEATABLE READ: 100 + 100 = 200The second is read skew: each value was committed, but they come from different moments, so a report or a balance check sees money that does not exist. Any multi-statement read that must be consistent needs REPEATABLE READ.
Lost update
bal = cur.execute("SELECT bal FROM acct WHERE id = 1").fetchone()[0]
cur.execute("UPDATE acct SET bal = %s WHERE id = 1", (bal + 10,))Two sessions running this concurrently from 100:
| Level | Second writer | Final (want 120) |
|---|---|---|
| READ COMMITTED | committed | 110: one update lost |
| REPEATABLE READ | 40001 concurrent update |
110, and an error |
RC, atomic + 10 |
waited, then applied | 120 |
Under READ COMMITTED the second write silently overwrote the first. Under
REPEATABLE READ PostgreSQL applies first-updater-wins: the second
transaction’s row was changed after its snapshot, so it fails with
could not serialize access due to concurrent update and must be retried.
MySQL’s REPEATABLE READ instead updates the latest version and loses it
silently (Isolation anomalies across databases).
The re-check after a wait
An atomic UPDATE ... SET bal = bal + 10 worked under READ COMMITTED because
an UPDATE that waits for a locked row re-reads the new version of that
row and re-evaluates its WHERE on it. Only on that row: it does not re-run
the whole query. The documentation’s own example:
session 1: UPDATE website SET hits = hits + 1; -- 9 -> 10, 10 -> 11, not committed
session 2: DELETE FROM website WHERE hits = 10; -- waits for session 1
session 1: COMMIT;
session 2: deleted 0 rows; table now [10, 11]A row with hits = 10 existed before and after, and nothing was deleted: the
old 10 had become 11 when re-checked, and the new 10 was not in the
statement’s snapshot. Complex WHERE clauses on contended rows can behave
like this at READ COMMITTED.
REPEATABLE READ: write skew
The doctors on call
Two doctors are on call; each may go off call if someone else is still on:
SELECT count(*) FROM oncall WHERE on_call; -- both see 2
UPDATE oncall SET on_call = false WHERE doctor = 'alice'; -- bob: his own row
COMMIT;| Level | Second COMMIT | Doctors on call |
|---|---|---|
| REPEATABLE READ | committed | 0 |
| SERIALIZABLE | 40001 read/write dependencies |
1 |
Each transaction updated a different row, so there was no write conflict to detect; each decided on a snapshot the other made false. That is write skew, and no snapshot can see it.
SERIALIZABLE: how SSI catches it
Predicate tracking
Serializable Snapshot Isolation runs like REPEATABLE READ and additionally
records what each transaction read, as SIReadLock entries:
pg_locks during the serializable SELECT:
('relation', 'SIReadLock', 'oncall')These are not locks anyone waits on; they let the server notice a dangerous
pattern, two transactions each reading something the other then wrote, and
cancel one with could not serialize access due to read/write dependencies among transactions. The cancellation can come at any statement or at
COMMIT, always before the effects become visible.
False positives and retries
Two consequences:
- False positives. The lock above covers the whole table because the query was a sequential scan; with an index it would cover only the rows or pages read. Coarser locks mean more conflicts that were not real anomalies, so missing indexes raise the abort rate.
- Retries are mandatory, for the whole transaction:
def run_serializable(engine, work, attempts=5):
for attempt in range(attempts):
try:
with Session(engine) as s, s.begin():
return work(s) # re-reads everything on retry
except OperationalError as e:
code = getattr(e.orig, "sqlstate", None)
if code not in ("40001", "40P01") or attempt == attempts - 1:
raise
time.sleep(0.01 * 2 ** attempt * random.random())Use the error’s sqlstate (psycopg 3; pgcode in psycopg2), not string
matching. Retry deadlocks (40P01) the same way. Never retry only the failed
statement: the decision was made on reads that are now stale.
Choosing a level, or locking instead
The booking that FOR UPDATE does not protect
SELECT * FROM booking WHERE slot = 5 FOR UPDATE; -- no row yet: locks nothing
INSERT INTO booking VALUES (5, 'ann');| Level | Second COMMIT | Bookings for slot 5 |
|---|---|---|
RC + FOR UPDATE |
committed | 2 |
| SERIALIZABLE | 40001 |
1 |
FOR UPDATE locks rows that exist. When the conflict is about a row that
does not exist yet, use a UNIQUE (slot) constraint, an exclusion
constraint for ranges (Index types), a lock on a
parent row that does exist (the slot’s own row), or SERIALIZABLE.
Row lock modes
| Clause | Blocks |
|---|---|
FOR UPDATE |
every other row lock and update |
FOR NO KEY UPDATE |
the same, except foreign-key checks |
FOR SHARE |
updates, not other readers’ locks |
FOR KEY SHARE |
only key changes and deletes |
Foreign-key checks take FOR KEY SHARE on the parent. An UPDATE that does
not change a key takes FOR NO KEY UPDATE, so inserting child rows does not
block on it; an explicit SELECT ... FOR UPDATE on the parent does block
those inserts. Prefer FOR NO KEY UPDATE when you lock a row to update
non-key columns. SKIP LOCKED turns a table into a work queue; NOWAIT fails
instead of waiting.
Where it breaks in production
Retry storms
A hot row under SERIALIZABLE or REPEATABLE READ: every retry conflicts again,
latency climbs, and the abort rate feeds itself. Count 40001 and 40P01
(%e in log_line_prefix, or application metrics); back off with jitter;
reduce contention on the row rather than adding attempts.
Long REPEATABLE READ reports
A report at REPEATABLE READ keeps one snapshot for its whole run, and for that
time VACUUM cannot remove anything newer
(PostgreSQL architecture). For a long
consistent read, BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE
waits for a safe snapshot and then can never be cancelled by SSI; run it on a
replica only at REPEATABLE READ, because standbys refuse SERIALIZABLE.
The level set too late, or leaking
SET TRANSACTION ISOLATION LEVEL must be the first statement of the
transaction, and an ORM may already have run one. SET SESSION CHARACTERISTICS under PgBouncer transaction pooling applies to whichever
client gets that server connection next. Set the level per transaction, in
the driver or ORM: psycopg’s conn.isolation_level, SQLAlchemy’s
execution_options(isolation_level=...), Django’s database OPTIONS.
Deadlocks
Two transactions lock rows in opposite order; after deadlock_timeout (1 s)
one is cancelled with 40P01. Lock in a consistent order (sort ids before
FOR UPDATE), turn on log_lock_waits, and use pg_blocking_pids() to see
who waits on whom.
The trade not taken
The alternative to SERIALIZABLE everywhere is READ COMMITTED plus hand-placed protection: atomic updates, row locks, unique and exclusion constraints, advisory locks (PostgreSQL Advisory Locks). It is faster and never aborts, but every invariant needs its own correct lock, and the booking case shows how easily one is missed.
SERIALIZABLE protects invariants nobody thought about, at the cost of predicate tracking and retries. A common compromise: READ COMMITTED by default, SERIALIZABLE for the few transactions that enforce cross-row rules, with a retry wrapper around them.
Related
Interview angle 6
- “What are the levels, and what does each prevent?” - READ COMMITTED: a snapshot per statement, so no dirty reads but non-repeatable reads, read skew and lost updates. REPEATABLE READ in PostgreSQL: one snapshot, so no phantoms, and lost updates raise an error; write skew remains. SERIALIZABLE: SSI aborts anything not equivalent to a serial order.
- “Does REPEATABLE READ prevent a lost update?” - in PostgreSQL the
second writer fails with
40001 ... concurrent update, so yes, if you retry. MySQL’s REPEATABLE READ updates the latest version and loses it.- Follow-up: “And at READ COMMITTED?” - a read in Python then a write
lost one of two increments;
SET bal = bal + 10did not, because the waiting update re-reads the row.
- Follow-up: “And at READ COMMITTED?” - a read in Python then a write
lost one of two increments;
- “What is write skew?” - two transactions read overlapping data and write different rows, each invalidating the other’s decision: both doctors went off call under REPEATABLE READ. SERIALIZABLE cancelled the second.
- “Two users book the last slot with
SELECT ... FOR UPDATEand still double-book. Why?” - there was no row to lock. Fix with a unique or exclusion constraint, a lock on an existing parent row, or SERIALIZABLE. - “Why not run everything at SERIALIZABLE?” - aborts and retries under
contention, more of them when queries scan whole tables, and it is not
available on replicas.
- Follow-up: “How do you write the retry?” - catch SQLSTATE
40001and40P01, re-run the whole transaction including its reads, back off with jitter, cap the attempts.
- Follow-up: “How do you write the retry?” - catch SQLSTATE
- “When is the snapshot taken?” - at the first statement, not at
BEGIN.