Skip to content
About
Main content

Write skew under Repeatable Read, and the two cures

Run a real systemBreak it on purposeNeeds Docker, Python

Problem

in PostgreSQL is : each transaction reads from a consistent , and two transactions that write the same row cannot both . slips through that rule. Two transactions read the same data, check an 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 (which detects the dangerous pattern and 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 ().

The question. Under Repeatable Read, does the owner's total go negative, and do Serializable and a materialized conflict each stop it?


Predict

Under Repeatable Read, two transactions each withdraw 80 against a joint total of 100. What does the database do?

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-repeatable-read-and.py71 linesDownload
    View 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)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    write-skew-under-repeatable-read-and.compose.yml12 linesDownload
    View 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: 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-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
Knobs
Knob · defaultWhat 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

Two withdrawals of 80 from a total of 100
LevelBoth readABFinal sum
REPEATABLE READ100committedcommitted-60
SERIALIZABLE100SQLSTATE 40001committed20
REPEATABLE READ + shared row100committedSQLSTATE 4000120
Reference runApple M2 · 8 cores · 8 GB
Output · uv run write-skew-under-repeatable-read-and.py
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 20
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 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 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.