Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Database Management Systems (DBMS)  Module 12 – Concurrency control

Serializable snapshot isolation and retries

How serializable snapshot isolation finds dangerous read-write dependencies, how to retry on 40001 and 40P01, and when to use SKIP LOCKED or unique constraints.

  • Advanced
  • 30 minutes
  • Examples run with sql.js 1.14.2, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free core, Pro depth: 10 of 30 minutes free
Free: From write skew to a dangerous structure · SERIALIZABLE in PostgreSQL · Retries · Exercise. With a pass: 2 in-depth parts and 3 interview answers, about 20 minutes.

What you will learn

  • Explain how serializable snapshot isolation detects dangerous rw-antidependency structures
  • Write retry loops for serialization failures (SQLSTATE 40001) and deadlocks (40P01)
  • Choose between FOR UPDATE, SKIP LOCKED, advisory locks, unique constraints and version columns

Before you start

What you’ll learn · Before you start

What you will learn

  • Explain how serializable snapshot isolation detects dangerous rw-antidependency structures
  • Write retry loops for serialization failures (SQLSTATE 40001) and deadlocks (40P01)
  • Choose between FOR UPDATE, SKIP LOCKED, advisory locks, unique constraints and version columns

Before you start

On this page

Serializable snapshot isolation (SSI) keeps the snapshots of snapshot isolation and adds one check. It watches for read-write dependencies between transactions that run at the same time, and when two of them meet at one transaction, a pattern called a dangerous structure, it aborts a transaction before the result can break serializability. Every anomaly that snapshot isolation allows, write skew included, contains such a structure. PostgreSQL’s SERIALIZABLE level is serializable snapshot isolation, and the aborted transaction receives SQLSTATE 40001. Retries are therefore part of using it: the application runs the whole transaction again. Retries are also how deadlock victims recover. The everyday patterns of application code, such as FOR UPDATE, SKIP LOCKED, advisory locks, unique constraints and version columns, decide where conflicts surface in the first place.

From write skew to a dangerous structure

Transaction A has a read-write antidependency on B when A reads an item and B, running at the same time, writes a newer version of it that A’s snapshot does not see: in any serial order equal to what happened, A must come before B. One such edge is harmless. Fekete and colleagues proved that every non-serializable execution under snapshot isolation contains two consecutive ones between concurrent transactions, T1 to T2 to T3, where T1 and T3 may be the same transaction (Making Snapshot Isolation Serializable). Cahill, Röhm and Fekete turned that into an algorithm: track the edges and abort when a transaction has one coming in and one going out (Serializable Isolation for Snapshot Databases).

Dangerous structure: T1, the report, has a rw edge to T2, the pivot, which has a rw edge to T3, which commits first.T1, the reportreads the batch number and the receiptsT2, the pivotreads the batch number, adds a receiptT3, closes the batchcommits firstrw: T1 did not see T2's receiptrw: T2 did not see T3's new batch number

The dangerous structure that serializable snapshot isolation looks for

Text description of the diagram

Three concurrent transactions and two read-write antidependencies, from top to bottom.

  1. T1, the report, reads the batch number and the receipts. Its arrow to T2 means T1 did not see the receipt that T2 adds.
  2. T2, the pivot, reads the batch number and adds a receipt. Its arrow to T3 means T2 did not see the new batch number that T3 writes.
  3. T3 closes the batch and commits first.

Two such edges in a row, with the last transaction committing first, form the dangerous structure. Serializable snapshot isolation aborts a transaction in it, here the pivot T2, because the three cannot be put in any serial order.

The program finds the edges and the structure in two histories, the doctors who both go off call and a report that reads a closed batch before its last receipt arrives:

Read-write edges, the dangerous structure and the transaction to abort Python · ssi_detector.py
"""Serializable snapshot isolation in miniature: the dependencies between concurrent transactions, the dangerous
structure, and the transaction PostgreSQL's rules abort.

Each transaction reads from the snapshot taken when it starts and writes when it commits. Tin -rw-> Tout means Tin
read an item and the concurrent Tout wrote a newer version that Tin's snapshot did not see. A dangerous structure is
two such edges in a row, Tin -rw-> Tpivot -rw-> Tout, where Tout commits first. PostgreSQL's rules: abort nothing
until Tout commits, then abort the pivot if it has not committed, otherwise Tin.
"""

HISTORIES = {
    "two doctors go off call": [
        ("start", "T1"), ("start", "T2"),
        ("read", "T1", "Meera"), ("read", "T1", "Arjun"), ("read", "T2", "Meera"), ("read", "T2", "Arjun"),
        ("write", "T1", "Meera"), ("write", "T2", "Arjun"),
        ("commit", "T1"), ("commit", "T2"),
    ],
    "a report sees a closed batch without its last receipt": [
        ("start", "T2"), ("read", "T2", "batch"),                     # T2 adds a receipt to the current batch
        ("start", "T3"), ("read", "T3", "batch"), ("write", "T3", "batch"), ("commit", "T3"),   # T3 closes it
        ("start", "T1"), ("read", "T1", "batch"), ("read", "T1", "receipts"), ("commit", "T1"), # T1 reports
        ("write", "T2", "receipts"), ("commit", "T2"),
    ],
}


def analyse(events):
    start, commit, reads, writes = {}, {}, {}, {}
    for i, (kind, t, *item) in enumerate(events):
        if kind == "start":
            start[t] = i
        elif kind == "commit":
            commit[t] = i
        elif kind == "read":
            reads.setdefault(t, set()).add(item[0])
        else:
            writes.setdefault(t, set()).add(item[0])
    names = sorted(start)
    concurrent = lambda a, b: start[a] < commit[b] and start[b] < commit[a]
    rw = sorted((a, b, x) for a in names for b in names if a != b and concurrent(a, b)
                for x in sorted(reads.get(a, set()) & writes.get(b, set())))
    lines = [f"{a} -rw-> {b}   ({a} read {x}; {b} wrote a newer version of {x} that {a} did not see)" for a, b, x in rw]
    edges = {(a, b) for a, b, _ in rw}
    victims = set()
    for tin, pivot in sorted(edges):
        for p2, tout in sorted(edges):
            if p2 == pivot and commit[tout] < commit[pivot] and commit[tout] <= commit[tin]:
                victim = pivot if commit[pivot] > commit[tout] else tin
                lines.append(f"dangerous structure {tin} -rw-> {pivot} -rw-> {tout}, and {tout} committed first: "
                             f"abort {victim}")
                victims.add(victim)
    if not victims:
        lines.append("no dangerous structure: every transaction commits")
    return lines


for label, events in HISTORIES.items():
    print(label)
    for line in analyse(events):
        print(f"  {line}")

Output

two doctors go off call
  T1 -rw-> T2   (T1 read Arjun; T2 wrote a newer version of Arjun that T1 did not see)
  T2 -rw-> T1   (T2 read Meera; T1 wrote a newer version of Meera that T2 did not see)
  dangerous structure T1 -rw-> T2 -rw-> T1, and T1 committed first: abort T2
a report sees a closed batch without its last receipt
  T1 -rw-> T2   (T1 read receipts; T2 wrote a newer version of receipts that T1 did not see)
  T2 -rw-> T3   (T2 read batch; T3 wrote a newer version of batch that T2 did not see)
  dangerous structure T1 -rw-> T2 -rw-> T3, and T3 committed first: abort T2

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ssi_detector.py

The doctors form the smallest structure, T1 to T2 and back to T1. The report is subtler: each pair of transactions alone could run in some order, but the three together cannot, although T1 only reads. In both histories the rule of Ports and Grittner applies: wait until the last transaction of the structure has committed, then abort the pivot if it has not committed yet (Serializable Snapshot Isolation in PostgreSQL).

SERIALIZABLE in PostgreSQL

PostgreSQL tracks what each serializable transaction read with SIReadLock predicate locks, which never block anybody. A transaction it aborts gets could not serialize access due to read/write dependencies among transactions, always with SQLSTATE 40001. The check can raise false alarms: some aborted transactions would have been serializable after all. Long reports can avoid the risk with SERIALIZABLE READ ONLY DEFERRABLE, which may wait once for a safe snapshot and then run without being able to cause or suffer a serialization failure (PostgreSQL 18: SET TRANSACTION).

Retries

The manual is clear on what a retry must repeat: the complete transaction, including the logic that decides which statements to run and with which values, which is why PostgreSQL does not retry on its own (PostgreSQL 18: Serialization Failure Handling). A retry wrapper therefore takes the transaction as a function, retries on 40001 and, as the manual also advises, on the deadlock code 40P01, waits a little longer before each new attempt, and gives up after a few. Retries must also be safe to repeat, so anything the transaction does outside the database needs an idempotency key.

The patterns that shape where conflicts appear, one sentence each:

  • SELECT ... FOR UPDATE locks the rows it reads, so a concurrent writer waits instead of failing later.
  • FOR UPDATE SKIP LOCKED passes over rows that other transactions have locked, which suits queue tables.
  • Advisory locks are locks on numbers whose meaning the application chooses, such as one per customer.
  • A unique constraint lets the database refuse the second of two conflicting inserts, whatever the isolation level.
  • A version column turns a lost update into an update that changes no row.
  • An idempotency key lets a retried request find the result of its earlier attempt instead of acting twice.

Exercise

Exercise · Medium · Python

Write a retry wrapper for serialization failures and deadlocks

Write run_with_retries(work, attempts=5, base=0.05, sleep=None) in retry.py.

  • work() runs one whole transaction, from BEGIN to COMMIT, and returns its result, or raises DatabaseError, whose .sqlstate holds the error code.
  • Retry only serialization failures ("40001") and deadlocks ("40P01"). Any other error is raised at once.
  • Call work() at most attempts times in all. When the last attempt fails, raise its error.
  • Before retry number n, wait base * 2 ** (n - 1) seconds by calling sleep(seconds), and never wait after the last attempt. The tests pass a sleep that records the waits instead of sleeping.
  • Return what the successful call of work() returned.

Starter code · retry.py

"""Retry a whole transaction when the database reports a serialization failure (40001) or a deadlock (40P01).

work() runs the whole transaction and returns its result, or raises DatabaseError with .sqlstate.
sleep(seconds) is how the wrapper waits; the tests pass a fake that records the waits.
"""


class DatabaseError(Exception):
    def __init__(self, sqlstate):
        super().__init__(f"SQLSTATE {sqlstate}")
        self.sqlstate = sqlstate


def run_with_retries(work, attempts=5, base=0.05, sleep=None):
    return work()
The sample tests · test_retry.py
from retry import DatabaseError, run_with_retries


def fake(*codes, result="committed"):
    """A transaction that fails with the given codes, one per call, and then succeeds."""
    calls = []

    def work():
        calls.append(len(calls) + 1)
        if len(calls) <= len(codes):
            raise DatabaseError(codes[len(calls) - 1])
        return result
    return work, calls


def test_two_serialization_failures_then_success():
    """40001 twice, then the third attempt commits"""
    work, calls = fake("40001", "40001")
    waits = []
    assert run_with_retries(work, sleep=waits.append) == "committed"
    assert len(calls) == 3
    assert waits == [0.05, 0.1]


def test_deadlock_is_retried():
    """40P01 is retried too"""
    work, calls = fake("40P01")
    assert run_with_retries(work, sleep=lambda s: None) == "committed"
    assert len(calls) == 2


def test_other_errors_are_not_retried():
    """A unique violation, 23505, is raised at once"""
    work, calls = fake("23505")
    waits = []
    try:
        run_with_retries(work, sleep=waits.append)
    except DatabaseError as e:
        assert e.sqlstate == "23505"
    else:
        assert False, "the unique violation should have been raised"
    assert len(calls) == 1 and waits == []


def test_gives_up_after_the_last_attempt():
    """After 3 failed attempts the last error is raised, with no wait after it"""
    work, calls = fake("40001", "40001", "40001", "40001")
    waits = []
    try:
        run_with_retries(work, attempts=3, sleep=waits.append)
    except DatabaseError as e:
        assert e.sqlstate == "40001"
    else:
        assert False, "the wrapper should give up after 3 attempts"
    assert len(calls) == 3
    assert waits == [0.05, 0.1]
A hint

Loop over the attempt numbers from 1 to attempts. Inside, call work() in a try; on DatabaseError, decide two things in order: is this code retryable at all, and is there an attempt left? Only when both are true do you sleep and go round again; otherwise raise the same error.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 What does a read-write antidependency from A to B mean?

    Choose one answer.

    Show the answer to question 1

    Answer: A read an item, and B, running at the same time, wrote a newer version that A's snapshot did not see

    A's read reflects the state before B's write, so in any serial order that matches what happened, A must come before B. Two such edges in a row between concurrent transactions are what SSI looks for.

  2. Question 2 of 5 In the doctors' history, which transaction did the rule abort, and why that one?

    Choose one answer.

    Show the answer to question 2

    Answer: T2, the pivot of T1 to T2 to T1, because T1 committed first and T2 had not committed yet

    Each doctor's transaction has an rw edge to the other. Once T1, the last transaction of the structure, has committed, the pivot T2 is the one to abort, and when it is retried it can no longer conflict with T1.

  3. Question 3 of 5 A transaction fails with SQLSTATE 40001 at its UPDATE. What should the application retry?

    Choose one answer.

    Show the answer to question 3

    Answer: The whole transaction, including the reads and the decisions made from them

    The transaction's reads came from a snapshot that turned out to be unsafe, so every decision based on them may be wrong. PostgreSQL's manual says to retry the complete transaction, including the logic that chose the SQL and its values.

  4. Question 4 of 5 Which errors should a generic retry wrapper retry on its own? Choose every one.

    Choose every answer that is right.

    Show the answer to question 4

    Answer:

    • 40001, serialization failure
    • 40P01, deadlock detected

    Serialization failures and deadlocks are transient: the same transaction can succeed when it runs again. A unique violation may be a lasting conflict, so it needs a decision by the code that knows what the key means, and a division by zero fails every time.

  5. Question 5 of 5 Twenty workers take jobs from one table. Which pattern lets each take a different job without waiting?

    Choose one answer.

    Show the answer to question 5

    Answer: SELECT ... FOR UPDATE SKIP LOCKED with LIMIT

    SKIP LOCKED passes over rows that other workers have locked, so each worker claims a different job without waiting for the others. That is the queue-like use PostgreSQL's manual describes for it.

Inside SSI: the commit-order rule, needless aborts and read-only transactions, measured Pro: opens with a Pro, Premium or Ultimate pass

Serializable snapshot isolation is conservative: it stops every anomaly and some executions that were fine. This part shows why two edges are enough to look for, measures anomalies and needless aborts on random histories, and explains the read-only optimisations and the settings that keep false alarms down.

In this part:

  • Why two consecutive edges are enough to look for: the theorem and its refinement
  • Anomalies and needless aborts on random histories, measured
  • Read-only transactions, safe snapshots and DEFERRABLE
  • What raises the false-alarm rate in PostgreSQL, and what lowers it

1 runnable example (Python) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Concurrency patterns in application code, with failures reproduced Pro: opens with a Pro, Premium or Ultimate pass

Most concurrency bugs in applications come from a pattern used where another one fits. This part measures what retries cost, drains a job queue three ways, enforces a rule with a unique constraint on a real engine, and ends with a table for choosing between the patterns.

In this part:

  • Retries done right, with numbers
  • A job queue drained three ways
  • A unique constraint and an idempotency key, on a real engine
  • Advisory locks and their keys, with numbers
  • Choosing a pattern

3 runnable examples (Python) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Interview questions

  1. Mid to senior

    A service runs its transactions at SERIALIZABLE on PostgreSQL. When a statement fails with SQLSTATE 40001, a colleague's code retries just that statement. Why is that wrong, and what should a correct retry wrapper do?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Senior to staff

    After a busy PostgreSQL service moves to SERIALIZABLE, 3 % of its transactions fail with 40001, and most of the failures look harmless. Where do they come from, and how would you bring the rate down without giving up serializability?

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    Design a jobs table in PostgreSQL for 20 workers so that no job runs twice and workers do not wait for each other. What does a worker's claim look like, and what happens when a worker crashes in the middle of a job?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Serializable snapshot isolation runs on snapshots and aborts a transaction when two read-write dependencies between concurrent transactions meet; every snapshot-isolation anomaly contains such a structure.
  • PostgreSQL’s SERIALIZABLE is SSI: its SIReadLock predicate locks never block, and its failures carry SQLSTATE 40001.
  • Retry the whole transaction on 40001 and 40P01, with backoff and a cap, and make side effects idempotent.
  • FOR UPDATE, SKIP LOCKED, advisory locks, unique constraints and version columns each move a conflict to a place where it is cheap to handle.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.