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 isolation level in PostgreSQL and many other databases, promises only that you never read uncommitted data. It does not stop a lost update: a transaction reads a value, another transaction changes it and commits, 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), lock 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
Read the balance: it is 200, not 250. The 50 is lost. With the fixes: (1) 250; (2) 0 rows on the first attempt, then a retry; (3) Session B blocks until A commits; and Repeatable Read fails with a serialization error instead of losing data.
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
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)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide 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: 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 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
| Knob · default | What 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
| Variant | Final balance | What happened |
|---|---|---|
| Read Committed, write what you read | 200 | A read 100 and wrote 200; B's +50 is lost |
| Fix 1: SET balance = balance + 100 | 250 | both changes kept |
| Fix 2: WHERE version = <read> | 250 | first attempt updated 0 rows; 1 retry, re-read, then wrote |
| Fix 3: SELECT ... FOR UPDATE | 250 | B blocked 1.00 s until A committed, then added 50 |
| Repeatable Read, write what you read | 150 | SQLSTATE 40001: could not serialize access due to concurrent update |
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 updateWhat 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 window 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 aborts, 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.