Write skew under snapshot isolation, and its fix
Run a real systemNeeds Docker, Python
Problem
Snapshot 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 write skew. SERIALIZABLE in PostgreSQL detects the dangerous read-write pattern and aborts 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 commit. 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 REPEATABLE READ both succeed and nobody is on shift. Under SERIALIZABLE one commit fails with could not serialize access due to read/write dependencies.
Run it
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 | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide 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)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide 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: 304Start 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
| Knob · default | What 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
| Isolation | Each saw on shift | Session 1 | Session 2 | On shift after |
|---|---|---|---|---|
| REPEATABLE READ | 2 | committed | committed | 0 |
| SERIALIZABLE | 2 | committed | serialization failure | 1 |
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/sWhat 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.