Write skew under REPEATABLE READ

Transactions, isolation and locking Senior Python 3.13+ PostgreSQL 17+ Reviewed By Best Latest Apps

Predict the output

Both sessions read, then each updates its own row, under REPEATABLE READ. What does the last line print?

Python 3.13
import psycopg
from psycopg import IsolationLevel

with psycopg.connect(autocommit=True) as c:
    c.execute("DROP TABLE IF EXISTS doctors")
    c.execute(
        "CREATE TABLE doctors (name text PRIMARY KEY, on_call bool)"
    )
    c.execute("INSERT INTO doctors VALUES ('ana', true), ('ben', true)")

a, b = psycopg.connect(), psycopg.connect()
a.isolation_level = b.isolation_level = IsolationLevel.REPEATABLE_READ
for conn, name in ((a, "ana"), (b, "ben")):
    conn.execute(
        "SELECT count(*) FROM doctors WHERE on_call"
    )  # both see 2
for conn, name in ((a, "ana"), (b, "ben")):
    conn.execute(
        "UPDATE doctors SET on_call = false WHERE name = %s", (name,)
    )
a.commit()
b.commit()
row = (
    psycopg.connect()
    .execute("SELECT count(*) FROM doctors WHERE on_call")
    .fetchone()
)
print(row)

Options
Hint 1 of 2

Which rows did each session write?

Hint 2 of 2

Snapshot isolation compares writes, not reads.

Choose an answer, then open Show answer below to check it.

Sessions A and B each count 2 doctors on call, then each takes only itself off call. READ COMMITTED and REPEATABLE READ both commit, leaving nobody on call; SERIALIZABLE fails B's commit with 40001, leaving one.

Run on PostgreSQL 17.11 (two sessions)

Text version

READ COMMITTED:

  1. A: SELECT count(*) … WHERE on_call (sees 2)
  2. B: SELECT count(*) … WHERE on_call (sees 2)
  3. A: UPDATE … SET on_call = false WHERE name = 'ana' (own row)
  4. B: UPDATE … SET on_call = false WHERE name = 'ben' (own row)
  5. A: COMMIT (ok)
  6. B: COMMIT (ok)

REPEATABLE READ:

  1. A: SELECT count(*) … WHERE on_call (sees 2)
  2. B: SELECT count(*) … WHERE on_call (sees 2)
  3. A: UPDATE … SET on_call = false WHERE name = 'ana' (own row)
  4. B: UPDATE … SET on_call = false WHERE name = 'ben' (own row)
  5. A: COMMIT (ok)
  6. B: COMMIT (ok)

SERIALIZABLE:

  1. A: SELECT count(*) … WHERE on_call (sees 2)
  2. B: SELECT count(*) … WHERE on_call (sees 2)
  3. A: UPDATE … SET on_call = false WHERE name = 'ana' (own row)
  4. B: UPDATE … SET on_call = false WHERE name = 'ben' (own row)
  5. A: COMMIT (ok)
  6. B: COMMIT (ERROR 40001, retry)
Across levels
LevelResultOn call
READ COMMITTED✕ Happens0
REPEATABLE READ✕ Happens0
SERIALIZABLE↻ 40001, retry1
Show answer

Answer: A (0,)

Correct. Both commits succeed: they wrote different rows, so snapshot isolation sees no conflict. Nobody is on call.

REPEATABLE READ only detects two transactions writing the same row. Here each changed a different row based on a count the other made stale, which is write skew, so both commit.

Why not the others?
  • B (1,). That's what SERIALIZABLE gives: one commit fails with 40001, so one doctor stays on call.
  • C (2,). Both updates did commit; neither was rolled back.
  • D The second commit() raises SerializationFailure. REPEATABLE READ only raises 40001 when two transactions update the same row.

30-second answer

Both transactions commit and nobody is on call. PostgreSQL's REPEATABLE READ is snapshot isolation: each transaction reads its own snapshot, sees two doctors on call, and updates a different row, so there is no write-write conflict to detect. This anomaly is write skew. SERIALIZABLE catches it and aborts one transaction with SQLSTATE 40001; the app must retry it.

Practise saying it · 45 s. No recording, just a timer

Explain write skew to an interviewer.

Say your answer out loud, then check which of these points you covered:

  • Both read a shared fact from their snapshot
  • Each writes a different row
  • No write-write conflict, so RR lets both commit
  • SERIALIZABLE aborts one with 40001
  • Fixes: retry loop, row locks or a constraint

Deep dive

Snapshot isolation only detects two transactions writing the same row. Write skew writes different rows based on a shared read.

PostgreSQL's SERIALIZABLE (serializable snapshot isolation, SSI) tracks read/write dependencies and aborts one transaction of a dangerous cycle.

Alternatives when you can't use SERIALIZABLE: lock the rows you read with SELECT ... FOR UPDATE, or turn the rule into a constraint the database enforces.

We ran this exact scenario on PostgreSQL 17: READ COMMITTED and REPEATABLE READ both left 0 doctors on call; SERIALIZABLE left 1.

Run the same race at three isolation levels Ran on Python 3.13.5 · mypy --strict
Python 3.13
import psycopg
from psycopg import IsolationLevel
from psycopg.rows import TupleRow


def reset() -> None:
    with psycopg.connect(autocommit=True) as c:
        c.execute("DROP TABLE IF EXISTS doctors")
        c.execute(
            "CREATE TABLE doctors"
            " (name text PRIMARY KEY, on_call bool NOT NULL)"
        )
        c.execute(
            "INSERT INTO doctors VALUES ('ana', true), ('ben', true)"
        )


def on_call(conn: psycopg.Connection[TupleRow]) -> int:
    row = conn.execute(
        "SELECT count(*) FROM doctors WHERE on_call"
    ).fetchone()
    assert row is not None
    n: int = row[0]
    return n


def both_go_off_call(level: IsolationLevel) -> None:
    reset()
    a, b = psycopg.connect(), psycopg.connect()
    a.isolation_level = b.isolation_level = level
    # Both check "someone else stays on call"...
    if on_call(a) >= 2 and on_call(b) >= 2:
        # ...then each takes only itself off call.
        a.execute(
            "UPDATE doctors SET on_call = false WHERE name = 'ana'"
        )
        b.execute(
            "UPDATE doctors SET on_call = false WHERE name = 'ben'"
        )
    results = []
    for conn in (a, b):
        try:
            conn.commit()
            results.append("commit")
        except psycopg.errors.SerializationFailure:
            results.append("40001")
        conn.close()
    with psycopg.connect() as c:
        print(level.name, results, "on call:", on_call(c))


for lvl in (
    IsolationLevel.READ_COMMITTED,
    IsolationLevel.REPEATABLE_READ,
    IsolationLevel.SERIALIZABLE,
):
    both_go_off_call(lvl)

Common mistakes

  • Believing REPEATABLE READ prevents every anomaly except phantoms, as the SQL standard table suggests.
  • Switching to SERIALIZABLE without adding a retry loop for 40001.
  • Fixing it with a check in application code, which reads the same stale snapshot.

Senior take & follow-ups

Name the anomaly, explain why snapshot isolation misses it (different rows, shared read), and give two fixes: SERIALIZABLE plus retries, or explicit locks or constraints.

Follow-up questions

Think of your answer first, then open the hint.

  1. Why doesn't FOR UPDATE on the count query alone fix it?

    Show hint

    Aggregates can't take row locks; lock the doctor rows you read instead (SELECT ... FOR UPDATE on the rows).

  2. Where else does write skew appear?

    Show hint

    Booking the last seat in two different rows, double-spending across two accounts, unique usernames checked in app code.

Suggest a fix if something here is wrong or out of date.