Write skew under Repeatable Read, and the two cures
Run a real systemBreak it on purposeNeeds Docker, Python
Problem
Repeatable Read in PostgreSQL is snapshot isolation: each transaction reads from a consistent snapshot, and two transactions that write the same row cannot both commit. Write skew slips through that rule. Two transactions read the same data, check an invariant across several rows, and then each write a different row. Neither overwrote the other, so both commit, and the invariant both checked is broken. The cures are Serializable (which detects the dangerous pattern and aborts one) or making the conflict a write on a shared row that both must touch.
Setup in PostgreSQL:
CREATE TABLE acct(owner int, kind text, balance int, PRIMARY KEY(owner, kind));
INSERT INTO acct VALUES (1,'main',60), (1,'savings',40);In two sessions, both run BEGIN ISOLATION LEVEL REPEATABLE READ; then SELECT sum(balance) FROM acct WHERE owner=1; (both see 100). Session A: UPDATE acct SET balance = balance - 80 WHERE owner=1 AND kind='main';. Session B: the same for 'savings'. Commit both. Check the sum: it is -60, with no error.
Repeat two ways. (a) Use ISOLATION LEVEL SERIALIZABLE in both. (b) Stay at Repeatable Read, but add UPDATE owners SET version = version + 1 WHERE id = 1; at the start of each transaction (materialize the conflict).
The question. Under Repeatable Read, does the owner's total go negative, and do Serializable and a materialized conflict each stop it?
Predict
Check the sum: it is -60, with no error. Under Serializable, expect the second commit to fail with SQLSTATE 40001 (serialization failure); your code must retry. With the materialized conflict, expect the second transaction to fail or wait on the shared row.
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-repeatable-read-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 Repeatable Read, and the two cures. Two transactions each check that an owner's total covers a withdrawal, then take 80 from different accounts. Under Repeatable Read (snapshot isolation) both commit and the total goes negative. Then Serializable, and Repeatable Read with the conflict materialized on a shared row. Needs the PostgreSQL service from the compose file (port 55461). """ import argparse, json, threading, time import psycopg p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55461/postgres", help="Connection string.") p.add_argument("--withdraw", type=int, default=80, help="Amount each transaction takes.") 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(): admin.execute("DROP TABLE IF EXISTS acct, owners") admin.execute("CREATE TABLE acct(owner int, kind text, balance int, PRIMARY KEY(owner, kind))") admin.execute("INSERT INTO acct VALUES (1,'main',60), (1,'savings',40)") admin.execute("CREATE TABLE owners(id int PRIMARY KEY, version int)") admin.execute("INSERT INTO owners VALUES (1, 0)") total = lambda: admin.execute("SELECT sum(balance) FROM acct WHERE owner = 1").fetchone()[0] def run(level, materialize): reset() a, b = psycopg.connect(args.dsn), psycopg.connect(args.dsn) out = {"A": "committed", "B": "committed"} for c in (a, b): c.execute(f"BEGIN ISOLATION LEVEL {level}") if materialize: a.execute("UPDATE owners SET version = version + 1 WHERE id = 1") seen = [c.execute("SELECT sum(balance) FROM acct WHERE owner = 1").fetchone()[0] for c in (a, b)] a.execute("UPDATE acct SET balance = balance - %s WHERE owner = 1 AND kind = 'main'", (args.withdraw,)) def b_side(): try: if materialize: # B touches the same row: it waits for A, then fails b.execute("UPDATE owners SET version = version + 1 WHERE id = 1") b.execute("UPDATE acct SET balance = balance - %s WHERE owner = 1 AND kind = 'savings'", (args.withdraw,)) b.commit() except psycopg.Error as e: out["B"] = f"SQLSTATE {e.sqlstate}"; b.rollback() tb = threading.Thread(target=b_side) t = time.perf_counter(); tb.start(); time.sleep(0.3) try: a.commit() except psycopg.Error as e: out["A"] = f"SQLSTATE {e.sqlstate}"; a.rollback() tb.join(); waited = time.perf_counter() - t a.close(); b.close() return {"variant": f"{level}{' + shared row' if materialize else ''}", "both_saw": seen[0], "A": out["A"], "B": out["B"], "final_sum": total(), "b_finished_after_s": waited} rows = [run("REPEATABLE READ", False), run("SERIALIZABLE", False), run("REPEATABLE READ", True)] for r in rows: print(f"{r['variant']:<30} both read {r['both_saw']} A: {r['A']:<15} B: {r['B']:<15} final sum {r['final_sum']}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"variants": rows, "summary": [{"rr_sum": rows[0]["final_sum"], "ser_sum": rows[1]["final_sum"], "ser_b": rows[1]["B"], "mat_sum": rows[2]["final_sum"], "mat_b": rows[2]["B"]}]}}, 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-repeatable-read-and.compose.yml # PostgreSQL for this lab. services: postgres: image: postgres:17.6 environment: POSTGRES_PASSWORD: lab ports: ["55461: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-repeatable-read-and.compose.yml up -d --wait uv run write-skew-under-repeatable-read-and.py uv run write-skew-under-repeatable-read-and.py --help # every knob docker compose -f write-skew-under-repeatable-read-and.compose.yml down -v
| Knob · default | What it changes |
|---|---|
| --dsnpostgresql://postgres:lab@localhost:55461/postgres | Connection string. |
| --withdraw80 | Amount each transaction takes. |
Our run
Two withdrawals of 80 from a total of 100
| Level | Both read | A | B | Final sum |
|---|---|---|---|---|
| REPEATABLE READ | 100 | committed | committed | -60 |
| SERIALIZABLE | 100 | SQLSTATE 40001 | committed | 20 |
| REPEATABLE READ + shared row | 100 | committed | SQLSTATE 40001 | 20 |
REPEATABLE READ both read 100 A: committed B: committed final sum -60
SERIALIZABLE both read 100 A: SQLSTATE 40001 B: committed final sum 20
REPEATABLE READ + shared row both read 100 A: committed B: SQLSTATE 40001 final sum 20What we found
Under Repeatable Read both transactions read a total of 100, both committed, and the sum ended at -60 with no error, as expected. Under Serializable one of the two failed with SQLSTATE 40001, a serialization failure the application must retry, and the sum stayed at 20. With the conflict materialized on the owner's row, B waited for A's lock on that row and then failed when A committed (SQLSTATE 40001), leaving 20.
Read it
Write skew is a mismatch between the unit the conflict detector tracks (a row) and the unit the invariant needs (the owner's total across rows). Snapshot isolation checks for write-write conflicts on rows, and these two transactions wrote different rows, so it saw nothing.
Serializable in PostgreSQL closes the gap by tracking read-write dependencies and aborting a transaction that would complete a dangerous cycle; the cost is retries, and occasional false aborts. Materializing the conflict closes it by hand: make every transaction that relies on the invariant write one shared row, so the row-level detector sees them collide. It works at any level, but only for the code paths that remember to do it.