Skip to content
About
Main content

Reproduce the lost update at Read Committed, then fix it three ways

Run a real systemBreak it on purposeNeeds Docker, Python

Problem

Read Committed, the default in PostgreSQL and many other databases, promises only that you never data. It does not stop a : a transaction reads a value, another transaction changes it and , and the first writes back a value computed from its stale read, silently erasing the other change. The fixes either let the database compute the new value (an atomic update), detect the conflict (a version check), the row before reading (SELECT … FOR UPDATE), or use a stronger level that refuses the second write.

Use PostgreSQL and two psql sessions. Setup:

CREATE TABLE accounts(id int PRIMARY KEY, balance int, version int DEFAULT 0);
INSERT INTO accounts VALUES (7, 100, 0);

Run this order of steps with both sessions at the default level (Read Committed). Session A: BEGIN; SELECT balance FROM accounts WHERE id=7; (sees 100). Session B: BEGIN; UPDATE accounts SET balance = balance + 50 WHERE id=7; COMMIT;. Session A: UPDATE accounts SET balance = 200 WHERE id=7; COMMIT;. Read the balance: it is 200, not 250. The 50 is lost.

Now repeat with each fix and write down what happens: (1) Session A uses SET balance = balance + 100 (result 250); (2) Session A uses WHERE id=7 AND version=0 and checks the row count it reports (0 rows means retry); (3) Session A starts with SELECT ... FOR UPDATE, and Session B blocks until A commits. Then run A with BEGIN ISOLATION LEVEL REPEATABLE READ and see the update fail with a serialization error instead of losing data.

The script drives both sessions from Python, in exactly this order.

The question. What balance does each variant leave, and how does each fix stop the 50 from being lost?


Predict

Under Repeatable Read, A writes back what it read while B's +50 commits first. What happens to A's write?

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.

    reproduce-the-lost-update-at-read.py104 linesDownload
    View the code
    reproduce-the-lost-update-at-read.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
    """Reproduce the lost update at Read Committed, then each fix.
    
    Two sessions on one account row in PostgreSQL. Session A reads the balance,
    session B adds 50 and commits, session A writes a value computed from what it
    read. Then the same interleaving with an atomic update, a version check,
    SELECT ... FOR UPDATE, and Repeatable Read.
    Needs the PostgreSQL service from the compose file (port 55460).
    """
    import argparse, json, threading, time
    import psycopg
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55460/postgres", help="Connection string.")
    p.add_argument("--hold", type=float, default=1.0, help="Seconds session A holds its row lock in the FOR UPDATE variant.")
    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 accounts")
        admin.execute("CREATE TABLE accounts(id int PRIMARY KEY, balance int, version int DEFAULT 0)")
        admin.execute("INSERT INTO accounts VALUES (7, 100, 0)")
    def balance():
        return admin.execute("SELECT balance FROM accounts WHERE id = 7").fetchone()[0]
    def session_b():
        with psycopg.connect(args.dsn) as b:              # BEGIN; UPDATE ... + 50; COMMIT (every writer bumps the version)
            b.execute("UPDATE accounts SET balance = balance + 50, version = version + 1 WHERE id = 7")
    
    rows = []
    def record(name, note):
        rows.append({"variant": name, "final_balance": balance(), "what_happened": note})
        print(f"{name:<34} final balance {rows[-1]['final_balance']:>4}   {note}")
    
    # 1. Read Committed: A reads 100, B adds 50, A writes 200 -> B's 50 is lost
    reset()
    with psycopg.connect(args.dsn) as a:
        seen = a.execute("SELECT balance FROM accounts WHERE id = 7").fetchone()[0]
        session_b()
        a.execute("UPDATE accounts SET balance = %s WHERE id = 7", (seen + 100,))
    record("Read Committed, write what you read", f"A read {seen} and wrote {seen + 100}; B's +50 is lost")
    
    # 2. Fix: an atomic update computed by the database
    reset()
    with psycopg.connect(args.dsn) as a:
        a.execute("SELECT balance FROM accounts WHERE id = 7")
        session_b()
        a.execute("UPDATE accounts SET balance = balance + 100 WHERE id = 7")
    record("Fix 1: SET balance = balance + 100", "both changes kept")
    
    # 3. Fix: optimistic version check, retry on 0 rows
    reset()
    retries = 0
    with psycopg.connect(args.dsn) as a:
        seen, ver = a.execute("SELECT balance, version FROM accounts WHERE id = 7").fetchone()
        session_b()
        while True:
            cur = a.execute("UPDATE accounts SET balance = %s, version = version + 1 WHERE id = 7 AND version = %s",
                            (seen + 100, ver))
            if cur.rowcount == 1: a.commit(); break
            a.rollback(); retries += 1                       # 0 rows: someone changed it; re-read and retry
            seen, ver = a.execute("SELECT balance, version FROM accounts WHERE id = 7").fetchone()
    record("Fix 2: WHERE version = <read>", f"first attempt updated 0 rows; {retries} retry, re-read, then wrote")
    
    # 4. Fix: SELECT ... FOR UPDATE, B blocks until A commits
    reset()
    waited = {}
    with psycopg.connect(args.dsn) as a:
        seen = a.execute("SELECT balance FROM accounts WHERE id = 7 FOR UPDATE").fetchone()[0]
        def b():
            t = time.perf_counter(); session_b(); waited["s"] = time.perf_counter() - t
        tb = threading.Thread(target=b); tb.start()
        time.sleep(args.hold)
        a.execute("UPDATE accounts SET balance = %s WHERE id = 7", (seen + 100,))
    tb.join()
    record("Fix 3: SELECT ... FOR UPDATE", f"B blocked {waited['s']:.2f} s until A committed, then added 50")
    
    # 5. Repeatable Read: A's write fails with a serialization error instead of losing B's update
    reset()
    error = ""
    with psycopg.connect(args.dsn) as a:
        a.execute("BEGIN ISOLATION LEVEL REPEATABLE READ")
        seen = a.execute("SELECT balance FROM accounts WHERE id = 7").fetchone()[0]
        session_b()
        try:
            a.execute("UPDATE accounts SET balance = %s WHERE id = 7", (seen + 100,))
        except psycopg.errors.SerializationFailure as e:
            error = f"SQLSTATE {e.sqlstate}: {str(e).splitlines()[0]}"
            a.rollback()
    record("Repeatable Read, write what you read", error or "no error")
    ver = admin.execute("SHOW server_version").fetchone()[0]
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"variants": rows, "summary": [{
                "lost": rows[0]["final_balance"], "atomic": rows[1]["final_balance"], "version": rows[2]["final_balance"],
                "for_update": rows[3]["final_balance"], "blocked_s": waited["s"], "rr": rows[4]["final_balance"],
                "rr_error": error, "server": ver}]}}, f)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    reproduce-the-lost-update-at-read.compose.yml12 linesDownload
    View the code
    reproduce-the-lost-update-at-read.compose.yml
    # PostgreSQL for this lab.
    services:
      postgres:
        image: postgres:17.6
        environment:
          POSTGRES_PASSWORD: lab
        ports: ["55460: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 reproduce-the-lost-update-at-read.compose.yml up -d --wait
    uv run reproduce-the-lost-update-at-read.py
    uv run reproduce-the-lost-update-at-read.py --help     # every knob
    docker compose -f reproduce-the-lost-update-at-read.compose.yml down -v
Knobs
Knob · defaultWhat it changes
--dsnpostgresql://postgres:lab@localhost:55460/postgres Connection string.
--hold1 Seconds session A holds its row lock in the FOR UPDATE variant.

Our run

The same interleaving, five ways

The same interleaving, five ways
VariantFinal balanceWhat happened
Read Committed, write what you read200A read 100 and wrote 200; B's +50 is lost
Fix 1: SET balance = balance + 100250both changes kept
Fix 2: WHERE version = <read>250first attempt updated 0 rows; 1 retry, re-read, then wrote
Fix 3: SELECT ... FOR UPDATE250B blocked 1.00 s until A committed, then added 50
Repeatable Read, write what you read150SQLSTATE 40001: could not serialize access due to concurrent update
Reference runApple M2 · 8 cores · 8 GB
Output · uv run reproduce-the-lost-update-at-read.py
Read Committed, write what you read final balance  200   A read 100 and wrote 200; B's +50 is lost
Fix 1: SET balance = balance + 100 final balance  250   both changes kept
Fix 2: WHERE version = <read>      final balance  250   first attempt updated 0 rows; 1 retry, re-read, then wrote
Fix 3: SELECT ... FOR UPDATE       final balance  250   B blocked 1.00 s until A committed, then added 50
Repeatable Read, write what you read final balance  150   SQLSTATE 40001: could not serialize access due to concurrent update
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

At Read Committed the final balance was 200: B's 50 was lost with no error, as expected. The atomic update gave 250, the version check 250 after one retry, and SELECT … FOR UPDATE 250, with session B blocked for 1 s until A committed. Under Repeatable Read, A's update failed with a serialization error (SQLSTATE 40001: could not serialize access due to concurrent update) and the balance stayed at 150: B's change kept, A's to be retried, nothing lost.


Read it

A lost update needs a read and a dependent write in two statements, with a commit by someone else in between: the of the first lab. Each fix closes it differently. The atomic update moves the read and the write into one statement, so there is no window. The version check lets the window happen and detects it, which costs a retry loop in the application. FOR UPDATE locks the row for the window, which costs waiting. Repeatable Read detects the conflict in the database and , which also costs a retry loop, but one the database enforces for every code path.

Most application code needs the first or the third: the read-modify-write pattern is everywhere, and few code paths are written to retry on a serialization failure.