Skip to content
About
Main content

Write skew under snapshot isolation, and its fix

Run a real systemNeeds Docker, Python

Problem

isolation prevents two transactions from writing the same row, but not from each reading a set of rows, deciding on that basis, and writing different rows. If each check was valid alone and the two writes together violate it, that is . SERIALIZABLE in PostgreSQL detects the dangerous read-write pattern and one transaction, which the application must retry; the retry rate is the price.

Create on_call(agent text primary key, on_shift boolean) with two rows both true. In two sessions, using BEGIN ISOLATION LEVEL REPEATABLE READ, each counts the agents on shift (both see 2), then session 1 sets a to false and session 2 sets b to false, then both . Repeat with ISOLATION LEVEL SERIALIZABLE. Wrap the transaction in a retry loop and measure the retry rate when 50 clients run it at once.

The question. Does each level let both sessions commit, and what retry rate does SERIALIZABLE cost under 50 concurrent clients?


Predict

Under SERIALIZABLE with 50 concurrent clients, what share of attempts must retry?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you. This lab also runs a real system, so you need Docker.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    write-skew-under-snapshot-isolation-and.py70 linesDownload
    View the code
    write-skew-under-snapshot-isolation-and.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["psycopg[binary]"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Write skew under snapshot isolation, and the cost of SERIALIZABLE.
    
    on_call(agent, on_shift) with two agents on shift. Two sessions each check
    that two are on shift, then each takes a different agent off, and both
    commit: under REPEATABLE READ nobody is left on shift; under SERIALIZABLE one
    commit fails. Then 50 clients run the same check-and-update against a pool of
    agents under SERIALIZABLE with a retry loop, and the retry rate is measured.
    Needs the PostgreSQL service from the compose file (port 55803).
    """
    import argparse, json, random, threading, time
    import psycopg
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55803/postgres", help="Connection string.")
    p.add_argument("--clients", type=int, default=50, help="Concurrent clients in the retry test.")
    p.add_argument("--agents", type=int, default=100, help="Agents in the retry test.")
    p.add_argument("--per-client", type=int, default=40, help="Transactions per client.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    admin = psycopg.connect(args.dsn, autocommit=True)
    def reset(n=2):
        admin.execute("DROP TABLE IF EXISTS on_call"); admin.execute("CREATE TABLE on_call (agent text PRIMARY KEY, on_shift boolean)")
        admin.execute("INSERT INTO on_call SELECT 'a' || g, true FROM generate_series(1, %s) g", (n,))
    rows = []
    for level in ("REPEATABLE READ", "SERIALIZABLE"):
        reset(); s1, s2 = psycopg.connect(args.dsn), psycopg.connect(args.dsn); outcome = []
        for s in (s1, s2): s.execute(f"BEGIN ISOLATION LEVEL {level}")
        seen = [s.execute("SELECT count(*) FROM on_call WHERE on_shift").fetchone()[0] for s in (s1, s2)]
        s1.execute("UPDATE on_call SET on_shift = false WHERE agent = 'a1'"); s2.execute("UPDATE on_call SET on_shift = false WHERE agent = 'a2'")
        for s in (s1, s2):
            try: s.execute("COMMIT"); outcome.append("committed")
            except psycopg.errors.SerializationFailure: outcome.append("serialization failure"); s.rollback()
        left = admin.execute("SELECT count(*) FROM on_call WHERE on_shift").fetchone()[0]
        rows.append({"isolation": level, "both_saw": seen[0], "session_1": outcome[0], "session_2": outcome[1], "on_shift_after": left})
    reset(args.agents); stats = {"commits": 0, "retries": 0}; lock = threading.Lock()
    def client(seed):
        rng = random.Random(seed); c = psycopg.connect(args.dsn)
        for _ in range(args.per_client):
            while True:
                try:
                    c.execute("BEGIN ISOLATION LEVEL SERIALIZABLE")
                    n = c.execute("SELECT count(*) FROM on_call WHERE on_shift").fetchone()[0]
                    a = f"a{rng.randrange(1, args.agents + 1)}"
                    c.execute("UPDATE on_call SET on_shift = NOT on_shift WHERE agent = %s AND (on_shift = false OR %s > 1)", (a, n))
                    c.execute("COMMIT")
                    with lock: stats["commits"] += 1
                    break
                except psycopg.errors.SerializationFailure:
                    c.rollback()
                    with lock: stats["retries"] += 1
    t = time.perf_counter(); ts = [threading.Thread(target=client, args=(i,)) for i in range(args.clients)]
    for th in ts: th.start()
    for th in ts: th.join()
    el = time.perf_counter() - t
    summary = {"clients": args.clients, "commits": stats["commits"], "retries": stats["retries"], "retry_rate_pct": stats["retries"] / (stats["commits"] + stats["retries"]) * 100,
               "commits_per_s": stats["commits"] / el}
    for r in rows: print(f"{r['isolation']:<16} both saw {r['both_saw']} on shift; session 1 {r['session_1']}, session 2 {r['session_2']}; on shift after: {r['on_shift_after']}")
    print(f"SERIALIZABLE with {args.clients} clients: {stats['commits']:,} commits, {stats['retries']:,} retries ({summary['retry_rate_pct']:.1f}% of attempts), {summary['commits_per_s']:.0f} commits/s")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"summary": summary, "tables": {"anomaly": rows}}, f)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    write-skew-under-snapshot-isolation-and.compose.yml13 linesDownload
    View the code
    write-skew-under-snapshot-isolation-and.compose.yml
    # PostgreSQL for the write-skew-under-snapshot-isolation-and lab.
    services:
      postgres:
        image: postgres:17.6
        command: ["postgres", "-c", "max_connections=200", "-c", "shared_buffers=256MB"]
        environment:
          POSTGRES_PASSWORD: lab
        ports: ["55803:5432"]
        healthcheck:
          test: ["CMD", "pg_isready", "-U", "postgres"]
          interval: 1s
          retries: 30
    
  4. 4Start the services, run the script with the defaults first, then change a knob from the table below and run it again. Remove the services when you are done.

    Terminal
    docker compose -f write-skew-under-snapshot-isolation-and.compose.yml up -d --wait
    uv run write-skew-under-snapshot-isolation-and.py
    uv run write-skew-under-snapshot-isolation-and.py --help     # every knob
    docker compose -f write-skew-under-snapshot-isolation-and.compose.yml down -v
Knobs
Knob · defaultWhat it changes
--dsnpostgresql://postgres:lab@localhost:55803/postgres Connection string.
--clients50 Concurrent clients in the retry test.
--agents100 Agents in the retry test.
--per-client40 Transactions per client.

Our run

Two sessions, two isolation levels

Two sessions, two isolation levels
IsolationEach saw on shiftSession 1Session 2On shift after
REPEATABLE READ2committedcommitted0
SERIALIZABLE2committedserialization failure1
Reference runApple M2 · 8 cores · 8 GB
Output · uv run write-skew-under-snapshot-isolation-and.py
REPEATABLE READ  both saw 2 on shift; session 1 committed, session 2 committed; on shift after: 0
SERIALIZABLE     both saw 2 on shift; session 1 committed, session 2 serialization failure; on shift after: 1
SERIALIZABLE with 50 clients: 2,000 commits, 28,137 retries (93.4% of attempts), 144 commits/s
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · postgres:17.6 · Docker 28.5.1 · run 2026-10-20

What we found

Under REPEATABLE READ both sessions saw 2 agents on shift and both committed, leaving 0 on shift. Under SERIALIZABLE the second commit got a serialization failure, and 1 agent stayed on shift. With 50 clients toggling agents under SERIALIZABLE, 28.1K of the attempts were retried (93.4% of all attempts) for 2K commits.


Read it

Snapshot isolation is not serializable, and the gap is exactly the "check a set, then write a member" pattern that business rules are made of. SERIALIZABLE closes it at the price of retries that grow with contention on the checked set, so every transaction must be retry-safe: no side effects outside the database before commit.