Skip to main content
RunBook Academy

PostgreSQLVII · MVCC, Transactions and VisibilityMVCC

Isolation levels as PostgreSQL implements them

Advanced⏱ ~35 minpsql

What you'll learn

  • Predict what each isolation level does to a given concurrent sequence
  • Recognise the two serialization failure messages and respond to each correctly
  • Explain write skew and why repeatable read does not detect it
  • Decide when SERIALIZABLE is the right answer and what it obliges the application to do

Prerequisites

Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27

Not yet marked complete on this device.

PostgreSQL accepts all four SQL isolation levels and implements three. READ UNCOMMITTED is accepted and behaves as READ COMMITTED, because the storage design has no way to show you an uncommitted version: dirty reads are not merely disallowed, they are unimplementable.

The three real levels differ in exactly one respect — when the snapshot is taken, and what conflicts are checked at commit.

LevelSnapshotDetects
READ COMMITTEDNew one per statementNothing; last writer wins after waiting
REPEATABLE READOne per transactionWrite-write conflicts
SERIALIZABLEOne per transactionWrite-write and read-write dependencies

Everything below is that table demonstrated.

Read committed

The default. Each statement gets a fresh snapshot, so a transaction can see different data on two executions of the same query.

Read-only / Safethe same query twice in one READ COMMITTED transaction
$ psql -U postgres -c "BEGIN ISOLATION LEVEL READ COMMITTED" -c "SELECT v FROM iso WHERE id=1" -c "SELECT pg_sleep(6)" -c "SELECT v FROM iso WHERE id=1" -c "COMMIT"
    step    | v
------------+----
first read | 10

           step              | v
-------------------------------+----
second read, same transaction | 99

This is a non-repeatable read, and under this level it is correct behaviour rather than an anomaly. The transaction asked twice and got two answers because two different things were true at the two moments.

Repeatable read

One snapshot for the whole transaction. The same query returns the same answer every time, whatever anyone else commits.

Read-only / Safethe identical sequence under REPEATABLE READ
$ psql -U postgres -c "BEGIN ISOLATION LEVEL REPEATABLE READ" -c "SELECT v FROM iso WHERE id=1" -c "SELECT pg_sleep(6)" -c "SELECT v FROM iso WHERE id=1" -c "COMMIT"
    step    | v
------------+----
first read | 10

           step              | v
-------------------------------+----
second read, same transaction | 10

Now the interesting case. What if the transaction then tries to write the row it has an outdated view of?

Service impact possiblea write conflict under REPEATABLE READ
$ psql -U postgres -c "BEGIN ISOLATION LEVEL REPEATABLE READ" -c "SELECT v FROM iso WHERE id=1" -c "UPDATE iso SET v = v + 1 WHERE id=1" -c "COMMIT"
ERROR:  could not serialize access due to concurrent update
ROLLBACK

It does not wait and then proceed. It refuses. The transaction’s whole guarantee is that it acts on a consistent view; writing v + 1 where v is a value it can no longer see would break that, so the only correct outcome is to abort.

Write skew: what repeatable read does not catch

Here is the case that justifies SERIALIZABLE existing.

A rota table. The business rule is that at least one person must be on duty. Two people, both on duty, both decide to sign off at the same moment. Each transaction checks the rule before acting.

BEGIN;
SELECT count(*) FROM on_call WHERE on_duty;   -- each reads 2, rule satisfied
UPDATE on_call SET on_duty = false WHERE name = <self>;
COMMIT;
Service impact possibleboth transactions under REPEATABLE READ
$ # two concurrent psql sessions, both at REPEATABLE READ
alice: COMMIT
bob:   COMMIT

name  | on_duty
-------+---------
alice | f
bob   | f

still_on_duty
---------------
           0

Both committed. Nobody is on duty. The business rule is broken and nothing anywhere reports an error.

And repeatable read was not at fault. Neither transaction read a value that changed. No row was written by both. There was no write-write conflict to detect — each wrote a different row. The conflict is between what one transaction read and what the other wrote, and repeatable read does not look at that.

This is write skew. It appears wherever a transaction reads a set of rows to check a condition and then writes a different row on the basis of it: inventory checks, capacity limits, uniqueness enforced in application code, approval quorums, double-booking.

Service impact possiblethe identical scenario under SERIALIZABLE
$ # the same two concurrent sessions, both at SERIALIZABLE
alice: COMMIT

bob:
ERROR:  could not serialize access due to read/write dependencies among transactions
DETAIL:  Reason code: Canceled on identification as a pivot, during write.
HINT:  The transaction might succeed if retried.
ROLLBACK

name  | on_duty
-------+---------
alice | f
bob   | t

One commits, one is refused, one person remains on duty. SERIALIZABLE tracked that bob’s write conflicted with what alice had read, found the pair could not be ordered as any serial execution, and cancelled one.

The two failure messages are not the same

They arrive with the same SQLSTATE (40001) and are handled the same way, but they tell you different things about your workload.

MessageMeansUsually indicates
could not serialize access due to concurrent updateTwo transactions wrote the same rowRow-level contention: a counter, a queue head, a hot record
could not serialize access due to read/write dependencies among transactionsA read-write cycle that has no serial equivalentA read-check-write pattern: the write skew shape

The first is often fixable in the application without changing isolation — move the arithmetic into the UPDATE, or restructure so that different transactions touch different rows. The second usually means the rule being enforced genuinely needs SERIALIZABLE, or an explicit lock, or a database-level constraint.

Choosing

Stay on READ COMMITTED unless something specific requires more. It is the default, it never aborts for isolation reasons, and most correctness problems in it are better solved by doing the arithmetic in the UPDATE or by SELECT … FOR UPDATE.

Use REPEATABLE READ for a transaction that must see one consistent point in time across many statements — a report, a consistency check, a multi-table export. Also note that pg_dump uses it, which is why a dump is a coherent snapshot rather than a smear across the dump’s duration.

Use SERIALIZABLE when a rule spans rows and cannot be expressed as a constraint: at least one on duty, no double-booking, capacity not exceeded. Then make the application retry, because it will be asked to.

What to take from this

  • Read committed takes a snapshot per statement; the other two take one per transaction.
  • Repeatable read detects write-write conflicts and raises could not serialize access due to concurrent update.
  • Write skew commits cleanly under repeatable read and produces broken invariants with no error.
  • Serializable detects read-write dependencies and raises could not serialize access due to read/write dependencies among transactions.
  • Both are SQLSTATE 40001. Retry the whole transaction from BEGIN, with backoff and a cap.

Cross-course references

  • Git, CI/CD & GitOps — Part CVI (Change management) covers getting an isolation-level change reviewed, because it changes application behaviour and belongs in a change record rather than in a configuration drift.
  • Observability for Production Sysadmins — Part LIX (Database observability) covers alerting on serialization failures, which are a normal cost of SERIALIZABLE and a defect only above a rate.

Quiz

Knowledge check · 6 questions

  1. Q1. Two concurrent transactions each read that two staff are on duty, then each sets a different one of them off duty. Both run at REPEATABLE READ and both commit. Why did neither fail?

  2. Q2. An application at READ COMMITTED reads a balance, subtracts an amount in application code, and writes the result back. Occasionally a withdrawal disappears with no error logged anywhere. What is the cheapest correct fix?

  3. Q3. A workload moved to SERIALIZABLE and its retry rate has climbed steadily as data volume grew, although the transactions touch the same small number of rows. What is the likely mechanism?

  4. Q4. Which statements about retrying serialization failures are correct? Select all that apply.

  5. Q5. READ UNCOMMITTED is accepted by PostgreSQL but behaves as READ COMMITTED, because the storage design provides no way to read an uncommitted row version.

  6. Q6. A team wants to enforce 'a meeting room cannot be double-booked' and asks whether to use SERIALIZABLE. What would you ask, and what alternatives would you weigh?

Passing score: 75%. Answers are checked in this browser.