Snapshot isolation has a marketing problem: it sounds like serializability. Each transaction reads from a frozen snapshot, writers don't block readers, and if two transactions update the same row, one of them aborts. PostgreSQL's own documentation is blunt about what's underneath: REPEATABLE READ "is implemented using a technique known in academic database literature and in some other database products as Snapshot Isolation," and its isolation-level table lists "serialization anomaly" as possible at that level. The gap between snapshot isolation and true serializability is exactly one anomaly family — and it slips through because both writes succeed.

Write skew: both transactions win, the invariant loses

The canonical shape: two transactions read overlapping data, then write to disjoint rows based on what they read. The PostgreSQL wiki's overdraft example makes it concrete. A customer holds two accounts with $500 each, and the bank allows a withdrawal as long as the combined balance stays non-negative:

sql
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT SUM(balance) FROM accounts WHERE customer = 42;  -- returns 1000
-- application logic: 1000 - 900 >= 0, approved
UPDATE accounts SET balance = balance - 900
 WHERE customer = 42 AND account = 'checking';
COMMIT;

Run two of these concurrently — one against checking, one against savings. Each reads the pre-withdrawal snapshot, sees $1,000, and approves a $900 withdrawal. Each updates a different row, so snapshot isolation's write-conflict check (first-committer-wins on the same tuple) never fires. Both commit. The customer is now $800 overdrawn, and no serial execution of the two transactions could have produced that state — a serial run would have had the second withdrawal see $100.

This is why write skew is nastier than the anomalies people usually test for. Dirty reads and lost updates involve a visible collision on the same data; write skew's write sets are disjoint by construction. The invariant lives only in the application's head, spread across rows the database sees as unrelated.

It gets worse: the anomaly doesn't even require the read-only observer to be innocent. The wiki's batch-closing example shows a read-only reporting transaction creating the inconsistency — it sees the batch counter incremented but not a receipt inserted into the old batch, an ordering no serial schedule allows. A pure SELECT can be the transaction that has to abort.

Every anomaly contains the same two-edge pattern

The fix could have been pessimistic: take shared locks on everything you read, i.e. two-phase locking. PostgreSQL instead ships Serializable Snapshot Isolation (SSI), introduced by Cahill, Röhm and Fekete in 2008 and first put into a production release in PostgreSQL 9.1 — described in detail in Ports and Grittner's VLDB 2012 paper.

SSI rests on a graph-theory result. Model concurrent execution as a serialization graph: an edge from T1 to T2 means T1 must precede T2 in any equivalent serial order. Three edge types exist — write/read dependencies, write/write dependencies, and rw-antidependencies, where T1 reads a version that T2 concurrently overwrites (so T1 must be ordered before T2, despite possibly committing after it). The execution is serializable if and only if this graph is acyclic.

Testing for cycles at commit time is expensive. Fekete et al. proved you don't have to (Theorem 1 as stated in the Ports and Grittner paper):

Every cycle in the serialization history graph contains a sequence of edges T1 →rw T2 →rw T3 where each edge is a rw-antidependency. Furthermore, T3 must be the first transaction in the cycle to commit.

So instead of searching for cycles, SSI watches each transaction for a "dangerous structure": one rw-antidependency coming in and one going out. If a transaction becomes such a pivot, SSI aborts one of the transactions involved. In the overdraft example the cycle has length two — each withdrawal reads a row the other writes, giving T1 →rw T2 →rw T1 — and one of them dies with SQLSTATE 40001:

ERROR:  could not serialize access due to read/write dependencies among transactions
HINT:  The transaction might succeed if retried.

Three consequences of checking the pattern rather than the cycle:

  1. Only rw edges need tracking. Dangerous structures are built entirely of rw-antidependencies, so SSI never records wr- or ww-dependency edges.
  2. It admits false positives. A dangerous structure isn't always part of an actual cycle, so occasionally a perfectly serializable execution gets a transaction aborted. PostgreSQL implements the commit-ordering refinement from Cahill's thesis — no abort is needed if T1 or T2 commits before T3 — which trims false positives without eliminating them. The precise variant (PSSI, which builds the full graph and tests cycles) was considered and rejected for its memory cost.
  3. It admits more concurrency than locking. Two-phase locking and classic optimistic concurrency control both forbid any rw-conflict between concurrent transactions. SSI allows single rw-antidependencies through untouched; only the two-in-a-row pattern triggers action.

SIREAD locks: a lock manager that never blocks anyone

To see rw-antidependencies, PostgreSQL needed reads to leave evidence — something its pure-MVCC engine never had. The result is a separate SSI lock manager holding a single lock mode, SIReadLock (visible in pg_locks). It cannot block and cannot deadlock; its only jobs are to record what a transaction read and to flag a conflict when a concurrent transaction writes a matching tuple. The reverse direction is caught by the ordinary MVCC visibility check: if you write a tuple and notice the version you're replacing is invisible to some concurrent serializable transaction's snapshot, that's an rw-antidependency too.

Predicate reads — "all rows where class = 1", including rows that don't exist yet — are handled with index-range locking, not true predicate locks: B+-tree reads take SIReadLock on index pages, so a later insert into that gap collides with the recorded read. Details that bite in production:

  • Granularity promotion. Locks are kept at tuple, page, or relation granularity, and many fine locks get collapsed into one coarse lock when the lock table (sized by max_pred_locks_per_transaction) runs short. Coarser locks mean more spurious conflicts and a higher abort rate.
  • Sequential scans lock the whole table. The docs are explicit that a seq scan "will always necessitate a relation-level predicate lock" — effectively marking the transaction as having read everything. On serializable workloads it can be worth nudging the planner toward index scans (lower random_page_cost, higher cpu_tuple_cost).
  • SIREAD locks outlive commit. A committed transaction's reads can still form an edge in a cycle with in-flight transactions, so its locks must be retained until concurrent transactions finish — a key reason bounding SSI's memory took real engineering.

What it costs

The Ports and Grittner paper measured SSI at "less than 7% relative to snapshot isolation" across their benchmarks; the CPU-bound DBT-2++ configuration showed about 5% slowdown. On the RUBiS web benchmark, snapshot isolation did 435 req/s, SSI 422, and two-phase locking 208 — SSI kept 97% of SI's throughput while S2PL lost half. Serialization failure rates stayed under 1%.

Two switches make the trade even better. Declaring transactions READ ONLY lets Postgres apply the paper's safe-snapshot optimization and drop conflict tracking once the snapshot is proven safe. SERIALIZABLE READ ONLY DEFERRABLE goes further: it waits for a provably safe snapshot (in the paper's benchmark, usually 1–6 seconds), then runs a long report with zero SSI overhead and zero abort risk — the only situation where a serializable transaction blocks when repeatable read wouldn't.

The non-negotiable part is the retry loop. SSI's aborts are not bugs and not always avoidable — false positives are part of the deal — so every serializable transaction needs a wrapper that catches 40001, discards any results, and reruns from the top. If your application checks invariants across rows — balances, on-call rosters, inventory sums — that retry loop plus SERIALIZABLE is dramatically cheaper to get right than hand-placed SELECT FOR UPDATE on rows you hope cover the invariant.

References