Snapshot Isolation and Serializable Snapshot Isolation

Database Engineering advanced Building with AI ~40 min read

Overview

Transaction isolation is the contract between application code and concurrent database execution. A database may run hundreds of transactions at the same time, but the application still wants a simple rule: if each transaction preserves the business invariant when it runs alone, the system should preserve that invariant when transactions overlap. Serializable isolation gives that rule. Weaker isolation levels trade some of that simplicity for lower latency, fewer aborts, or less locking.

Snapshot isolation is one of the most important middle points in that trade-off. A transaction reads from a stable snapshot of committed data, so repeated reads are consistent and readers do not block writers. It also prevents concurrent transactions from overwriting the same row. That combination is fast and intuitive, which is why MVCC databases often use it or a close relative for repeatable-read behavior. The catch is that row-level write conflict checks are not enough to preserve cross-row invariants.

Serializable Snapshot Isolation, or SSI, keeps the snapshot-isolation execution model but adds runtime dependency tracking. Instead of locking every predicate read the way strict two-phase locking would, SSI watches for dangerous structures in the serialization graph: overlapping transactions whose read-write dependencies could form a cycle. When such a structure appears, the database aborts one transaction and asks the client to retry. PostgreSQL 9.1 was the first production database release to implement SSI as its serializable isolation level.

This topic focuses on the isolation taxonomy and SSI algorithm. It complements MVCC internals by answering a different operational question: when is snapshot isolation enough, when can it silently corrupt an invariant, and how does a serializable MVCC database detect that risk without turning every read into a blocking lock?

Core Concepts

Isolation Levels Are Behavioral Contracts

Read committed, snapshot isolation, repeatable read, and serializable are not implementation names. They describe histories the database is allowed to expose. PostgreSQL’s default read committed level gives each statement its own fresh snapshot; it prevents dirty reads but allows a transaction to observe different committed states across statements. PostgreSQL repeatable read uses one snapshot for the transaction and may abort if a row it wants to update has changed since that snapshot.

Serializable isolation is stronger: the outcome must be equivalent to some one-at-a-time ordering of the committed transactions. That does not mean the database actually runs transactions sequentially. It means the database must either schedule, lock, validate, or abort transactions so that the committed result has a valid serial explanation.

Snapshot Isolation

Under snapshot isolation, a transaction sees a consistent snapshot as of its start time or first statement, depending on the database. Writes usually take row-level locks, and two concurrent transactions cannot both commit updates to the same row. This prevents dirty reads, non-repeatable reads, many lost updates, and many phantom-like surprises.

Snapshot isolation is still not serializable. The classic anomaly is write skew: two transactions both read a predicate, make decisions based on the same old state, and update different rows. Since neither writes the other’s row, the write-write conflict detector sees no conflict, but the final state can violate an invariant.

Read-Write Dependencies

SSI is built around read-write anti-dependencies. If transaction T1 reads a version, and concurrent transaction T2 later writes a replacement version that T1 did not see, then T1 has a read-write dependency to T2. A single dependency is not necessarily bad. A dangerous pattern appears when a transaction has an incoming and outgoing read-write dependency among overlapping transactions.

Key Concept: Dangerous Structure

SSI does not try to build a complete serialization graph for every workload. It looks for a compact signal: two adjacent read-write dependencies among concurrent transactions. That pattern is a necessary condition for snapshot-isolation anomalies, so aborting one participant before commit prevents a non-serializable cycle.

Key Concept: Predicate Reads Matter

A transaction can depend on rows that do not yet exist. For example, checking WHERE on_call = true is a predicate over a set, not just a read of the rows returned today. SSI systems need a way to track predicate read dependencies, often using index-range or page-level structures that behave like non-blocking predicate locks.

How It Works

The Write-Skew Failure

Suppose a hospital requires at least one doctor to remain on call. Alice and Bob are both on call. Two transactions each verify that at least two doctors are currently on call, then take one doctor off call. Under snapshot isolation both transactions read the original two-row snapshot. They update different rows, so there is no row-level write-write conflict. Both commit, leaving zero doctors on call.

-- T1 snapshot sees Alice and Bob on call
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call = true; -- returns 2
UPDATE doctors SET on_call = false WHERE name = 'Alice';
COMMIT;

-- T2 overlaps with T1 and sees the same original snapshot
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call = true; -- returns 2
UPDATE doctors SET on_call = false WHERE name = 'Bob';
COMMIT;

There is no serial order that produces this result while preserving the transaction logic. If T1 went first, T2 would read only one doctor on call and would not update Bob. If T2 went first, T1 would not update Alice. The anomaly exists because the conflict is over the predicate “at least one doctor remains on call,” not over a single row.

SSI Runtime Detection

SSI runs the transactions on snapshots, but records reads and writes. When a write changes a row or range previously read by a concurrent transaction, the database records a read-write dependency. If a transaction becomes the middle of a dangerous structure, the database aborts one transaction, usually choosing a transaction whose retry has a safe chance of succeeding.

type Txn struct {
    id          string
    snapshotTs  int64
    readRanges  []Range
    writeKeys   []Key
    inRW        map[string]bool  // other txn -> this txn
    outRW       map[string]bool  // this txn -> other txn
    committed   bool
}

func onWrite(writer *Txn, key Key, active []*Txn) error {
    for _, reader := range active {
        if reader.id == writer.id {
            continue
        }
        if reader.snapshotTs < writer.snapshotTs && reader.readRanges.Contains(key) {
            reader.outRW[writer.id] = true
            writer.inRW[reader.id] = true
        }
    }
    return nil
}

func validateSSI(tx *Txn, all map[string]*Txn) error {
    for predecessor := range tx.inRW {
        for successor := range tx.outRW {
            if predecessor != successor && overlaps(all[predecessor], tx, all[successor]) {
                return SerializationFailure{Retry: true}
            }
        }
    }
    return nil
}

Safe Snapshots for Read-Only Transactions

Read-only transactions can still participate in snapshot-isolation anomalies, especially long analytical reads that observe a state no serial execution could expose. PostgreSQL’s SSI implementation added safe snapshots and deferrable read-only transactions to reduce unnecessary tracking. A deferrable read-only serializable transaction can wait until the database knows its snapshot cannot be part of a dangerous structure, then run without SSI overhead.

Practical Applications

Invariant-Critical OLTP

SSI is valuable when correctness depends on predicates spanning multiple rows: inventory reservations, account limits, appointment capacity, authorization rules, workflow state transitions, and medical or legal records. These workloads often look safe under row-level update tests because each transaction writes a different row. The invariant lives above the row.

Generated SQL and Ad Hoc Queries

The PostgreSQL SSI paper highlights a real operational motivation: in a complex schema with many developers, object-relational mappers, generated queries, and administrative SQL, manually proving that every pair of transactions is safe under snapshot isolation is not realistic. Serializable isolation moves that burden into the database. The application must still retry serialization failures, but it no longer has to encode every concurrency hazard as explicit locks.

Read-Heavy Serializable Workloads

Traditional strict two-phase locking can make readers block writers and writers block readers. SSI is attractive for read-heavy systems because reads still come from MVCC snapshots. The cost shifts from blocking to dependency tracking, memory management, and occasional aborts. That is usually a better shape for dashboards, reporting transactions, and mixed OLTP workloads where long reads coexist with short writes.

Implementation Patterns

Retry Serialization Failures at the Transaction Boundary

Serializable isolation is not a magic mode that removes application responsibility. The database may abort a transaction that could create an anomaly. Application code must make transactions idempotent enough to retry from the beginning, including external side effects such as emails, webhooks, or payment calls.

func WithSerializable(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error {
    for attempt := 1; attempt <= 5; attempt++ {
        tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
        if err != nil {
            return err
        }

        err = fn(tx)
        if err == nil {
            err = tx.Commit()
        }
        if err == nil {
            return nil
        }

        _ = tx.Rollback()
        if !isSerializationFailure(err) {
            return err
        }

        jitter := time.Duration(rand.Intn(1<<attempt)) * 10 * time.Millisecond
        time.Sleep(jitter)
    }
    return errors.New("serializable transaction failed after retries")
}

Materialize Conflicts When SSI Is Unavailable

Some systems expose snapshot isolation but not SSI. One fallback is to materialize the invariant as a row that every transaction must update. In the on-call example, an on_call_guard row can force both transactions to contend on one write-write conflict. This is less elegant than SSI because the application author must know every invariant and remember to touch the guard row.

BEGIN;
SELECT * FROM invariant_guard WHERE name = 'doctor_on_call' FOR UPDATE;

SELECT count(*) FROM doctors WHERE on_call = true;
-- update only if the invariant remains valid
UPDATE doctors SET on_call = false WHERE name = $1;

COMMIT;

Use Declarative Constraints Where Possible

Serializable isolation protects transaction interaction, but constraints are still better for local facts. Unique constraints, foreign keys, exclusion constraints, and check constraints are deterministic, visible in schema review, and enforced even when application code changes. Use serializable isolation for invariants that cannot be expressed as a simple database constraint.

Performance Considerations

Common Pitfalls

Further Reading