Serializable
You got here from REPEATABLE READ, where your snapshot never shifts mid-transaction, so a shared invariant should be safe. It isn't. Two doctors are on call. Two transactions each check that at least one is still on call, each see two, and each take their own doctor off. Different rows, so nothing conflicts; both commit; the ward is now empty. No single transaction did anything wrong. That's write skew, and nothing weaker than SERIALIZABLE stops it.
SERIALIZABLE is the strongest level, and the one with a mental model you can actually hold: if every transaction runs SERIALIZABLE and commits, the result is guaranteed to equal some one-at-a-time execution of them. You reason about each transaction on its own and let the database guarantee the combination.
PostgreSQL implements this as Serializable Snapshot Isolation (SSI): REPEATABLE READ snapshots plus tracking of the read/write dependencies between concurrent transactions. When a pattern appears that could produce a non-serializable outcome, it aborts one transaction with SQLSTATE 40001 — keep it, retry it, done.
Why REPEATABLE READ isn't enough: write skew
Here's that on-call invariant as a live run at REPEATABLE READ. Two transactions, two different rows, both commit — and the rule they both checked is broken, with no error raised to warn you:
Hospital rule: at least one doctor must stay on call. Alice and Bob both want the night off.
A> BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN
B> BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN
A> SELECT count(*)::int AS on_call FROM doctors WHERE on_call; -- "two of us — safe for me to leave"
on_call
---------
2
(1 row)
B> SELECT count(*)::int AS on_call FROM doctors WHERE on_call; -- "two of us — safe for me to leave"
on_call
---------
2
(1 row)Each updates a DIFFERENT row, so there is no write-write conflict to detect.
A> UPDATE doctors SET on_call = false WHERE name = 'alice';
UPDATE 1
B> UPDATE doctors SET on_call = false WHERE name = 'bob';
UPDATE 1
A> COMMIT;
COMMIT
B> COMMIT; -- both succeed!
COMMIT
A> SELECT count(*)::int AS on_call FROM doctors WHERE on_call; -- nobody is on call — the invariant is broken
on_call
---------
0
(1 row)Each transaction was internally consistent; together they broke the rule. Only SERIALIZABLE catches this.
Verified against PostgreSQL 18.4 · Run it yourself · Scenario source
The same interleaving, SERIALIZABLE
Same story, same statements, same order — only the isolation level differs.
A> BEGIN ISOLATION LEVEL SERIALIZABLE;
BEGIN
B> BEGIN ISOLATION LEVEL SERIALIZABLE;
BEGIN
A> SELECT count(*)::int AS on_call FROM doctors WHERE on_call;
on_call
---------
2
(1 row)
B> SELECT count(*)::int AS on_call FROM doctors WHERE on_call;
on_call
---------
2
(1 row)
A> UPDATE doctors SET on_call = false WHERE name = 'alice';
UPDATE 1
B> UPDATE doctors SET on_call = false WHERE name = 'bob';
UPDATE 1The first committer wins. The second cannot be serialized against it and is aborted.
A> COMMIT;
COMMIT
B> COMMIT; -- serialization_failure
ERROR: 40001: could not serialize access due to read/write dependencies among transactions
A> SELECT count(*)::int AS on_call FROM doctors WHERE on_call; -- the invariant survived
on_call
---------
1
(1 row)B's job is to retry. On retry it would see only one doctor on call — and refuse the night off.
Verified against PostgreSQL 18.4 · Run it yourself · Scenario source
It even protects read-only transactions
The strangest anomaly in this chapter: at REPEATABLE READ, a read-only report can observe a state that no serial ordering of the transactions could ever produce — the numbers it printed become retroactively wrong. Under SERIALIZABLE, PostgreSQL aborts the writer that would invalidate the already-committed report:
A bank tracks receipts per deposit batch. The cashier files a receipt into the current batch (1) — slowly.
Cashier> BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN
Cashier> SELECT deposit_no FROM control;
deposit_no
------------
1
(1 row)
Cashier> INSERT INTO receipts VALUES (3, 1, 400);
INSERT 0 1Meanwhile the closer moves the bank to batch 2 — from now on, batch 1 is supposedly complete.
Closer> BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN
Closer> UPDATE control SET deposit_no = 2;
UPDATE 1
Closer> COMMIT;
COMMITAn auditor prints the report for the closed batch 1.
Report> BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN
Report> SELECT deposit_no FROM control; -- batch 1 is closed…
deposit_no
------------
2
(1 row)
Report> SELECT receipt_no, amount FROM receipts WHERE deposit_no = 1 ORDER BY receipt_no; -- …and contains receipts 1 and 2, total 300. The report is published.
receipt_no | amount
------------+--------
1 | 100
2 | 200
(2 rows)
Report> COMMIT;
COMMITNow the cashier's receipt lands — in batch 1, which the report just declared final.
Cashier> COMMIT;
COMMIT
Report> SELECT receipt_no, amount FROM receipts WHERE deposit_no = 1 ORDER BY receipt_no; -- the published report was wrong: no serial order of these three transactions produces it
receipt_no | amount
------------+--------
1 | 100
2 | 200
3 | 400
(3 rows)Rewind and replay the exact same interleaving — all three transactions SERIALIZABLE.
Report> DELETE FROM receipts WHERE receipt_no = 3;
DELETE 1
Report> UPDATE control SET deposit_no = 1;
UPDATE 1
Cashier> BEGIN ISOLATION LEVEL SERIALIZABLE;
BEGIN
Cashier> SELECT deposit_no FROM control;
deposit_no
------------
1
(1 row)
Cashier> INSERT INTO receipts VALUES (3, 1, 400);
INSERT 0 1
Closer> BEGIN ISOLATION LEVEL SERIALIZABLE;
BEGIN
Closer> UPDATE control SET deposit_no = 2;
UPDATE 1
Closer> COMMIT;
COMMIT
Report> BEGIN ISOLATION LEVEL SERIALIZABLE;
BEGIN
Report> SELECT deposit_no FROM control;
deposit_no
------------
2
(1 row)
Report> SELECT receipt_no, amount FROM receipts WHERE deposit_no = 1 ORDER BY receipt_no;
receipt_no | amount
------------+--------
1 | 100
2 | 200
(2 rows)
Report> COMMIT; -- the report itself commits fine —
COMMIT— because SERIALIZABLE protects it by aborting the transaction that would invalidate it: the cashier.
Cashier> COMMIT;
ERROR: 40001: could not serialize access due to read/write dependencies among transactions
Report> SELECT receipt_no, amount FROM receipts WHERE deposit_no = 1 ORDER BY receipt_no; -- batch 1 still matches the published report; the cashier retries into batch 2
receipt_no | amount
------------+--------
1 | 100
2 | 200
(2 rows)Verified against PostgreSQL 18.4 · Run it yourself · Scenario source
Living with SERIALIZABLE
A swallowed 40001 is a lost write
Logging a serialization failure and moving on means the transaction never happened. 40001 is not an error to report — it's an instruction to retry.
Retries aren't optional at this level. Any serializable transaction, even a read-only one, can be aborted with 40001, so your application has to retry, and a small wrapper makes that painless. Some of those aborts are false positives: SSI's dependency tracking is deliberately conservative and will occasionally cancel a transaction that would have been fine. The manual notes that when memory pressure forces page-level predicate locks to combine into relation-level ones, "an increase in the rate of serialization failures may occur". A false positive costs you a retry, never correctness.
Keep transactions short and small for the same reason. Dependency tracking is bounded by max_pred_locks_per_transaction and its _per_relation and _per_page siblings, and long transactions and sequential scans widen the conflict surface. For read-only work that must never be aborted or drag others down, BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE may block while it acquires a safe snapshot, then runs "without any risk of contributing to or being canceled by a serialization failure" — the manual calls it "well suited for long-running reports or backups".
The trade in one breath: SERIALIZABLE is REPEATABLE READ plus dependency monitoring rather than locks, so readers still never block writers, and it's the only level that stops write skew and the read-only anomaly on its own. Invariants that span multiple rows are safe here, or with the explicit locking of the next chapter, and nowhere weaker. Design for 40001 from the first day: retry loops, idempotent transaction bodies, and no side effects like emails or HTTP calls inside the transaction.
Further reading
- PostgreSQL docs: Serializable Isolation Level
- PostgreSQL wiki: SSI examples — the source of the deposit-report example above
- Ports & Grittner, Serializable Snapshot Isolation in PostgreSQL (VLDB 2012) — the paper behind PostgreSQL's SSI implementation
- The same lesson on MySQL