Pessimistic versus optimistic crossover
Run a real systemNeeds Docker, Python
Problem
Pessimistic concurrency locks a row before reading it, so conflicting transactions wait in line; optimistic concurrency reads freely and checks at write time that nobody changed the row (a version column), retrying on conflict. Optimism wins when conflicts are rare, because it never waits; when they are common, most of its work is thrown away, and goodput (successful commits) falls while attempts stay high.
A shared counter table of K rows; each of 16 workers does a read-modify-write on one random row for 15 seconds, once with SELECT ... FOR UPDATE and once with the version-column pattern (retry when 0 rows are updated). Run with K = 1,000,000, 10,000, 100 and 1. Record attempts per second, goodput and the abort rate.
The question. At which K does optimism start to lose, and what happens to its goodput when every worker wants the same row?
Predict
Expect optimism to win when K is large, and to burn CPU with falling goodput when K is small, while locking degrades by queueing. The crossover should be at an abort probability of a few percent.
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
pessimistic-versus-optimistic-crossover.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 """Pessimistic against optimistic: the crossover. A PostgreSQL counter table of K rows; 16 workers each do read-modify-write on one random row for 15 seconds, either with SELECT ... FOR UPDATE (pessimistic) or with a version column and retry when the conditional UPDATE changes 0 rows (optimistic). For K from 1,000,000 down to 1: attempts per second, successful commits per second (goodput) and the abort probability. Needs the PostgreSQL service from the compose file (port 55804). """ import argparse, json, random, threading, time import psycopg p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55804/postgres", help="Connection string.") p.add_argument("--keys", default="1000000,10000,100,1", help="Values of K.") p.add_argument("--workers", type=int, default=16, help="Workers.") p.add_argument("--seconds", type=float, default=15, help="Seconds per run.") 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 run(K, mode): admin.execute("DROP TABLE IF EXISTS counters"); admin.execute("CREATE TABLE counters (id int PRIMARY KEY, n int, version int)") admin.execute("INSERT INTO counters SELECT g, 0, 0 FROM generate_series(1, %s) g", (K,)); admin.execute("VACUUM ANALYZE counters") stats = {"attempts": 0, "commits": 0}; lock = threading.Lock(); stop = time.time() + args.seconds def worker(seed): rng = random.Random(seed); c = psycopg.connect(args.dsn); a = ok = 0 while time.time() < stop: k = rng.randrange(1, K + 1); a += 1 if mode == "pessimistic": with c.transaction(): n = c.execute("SELECT n FROM counters WHERE id = %s FOR UPDATE", (k,)).fetchone()[0] c.execute("UPDATE counters SET n = %s WHERE id = %s", (n + 1, k)) ok += 1 else: with c.transaction(): n, v = c.execute("SELECT n, version FROM counters WHERE id = %s", (k,)).fetchone() cur = c.execute("UPDATE counters SET n = %s, version = version + 1 WHERE id = %s AND version = %s", (n + 1, k, v)) ok += cur.rowcount # 0 rows: someone else won, retry with lock: stats["attempts"] += a; stats["commits"] += ok ts = [threading.Thread(target=worker, args=(i,)) for i in range(args.workers)] for t in ts: t.start() for t in ts: t.join() return {"K": K, "mode": mode, "attempts_per_s": stats["attempts"] / args.seconds, "goodput_per_s": stats["commits"] / args.seconds, "abort_pct": (1 - stats["commits"] / stats["attempts"]) * 100 if stats["attempts"] else None} rows = [run(int(K), m) for K in args.keys.split(",") for m in ("pessimistic", "optimistic")] for r in rows: print(f"K = {r['K']:>9,} {r['mode']:<12} attempts {r['attempts_per_s']:7.0f}/s goodput {r['goodput_per_s']:7.0f}/s aborts {r['abort_pct']:5.1f}%") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"runs": rows}}, f)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide the code
pessimistic-versus-optimistic-crossover.compose.yml # PostgreSQL for the pessimistic-versus-optimistic-crossover lab. services: postgres: image: postgres:17.6 command: ["postgres", "-c", "max_connections=200", "-c", "shared_buffers=256MB"] environment: POSTGRES_PASSWORD: lab ports: ["55804: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 pessimistic-versus-optimistic-crossover.compose.yml up -d --wait uv run pessimistic-versus-optimistic-crossover.py uv run pessimistic-versus-optimistic-crossover.py --help # every knob docker compose -f pessimistic-versus-optimistic-crossover.compose.yml down -v
| Knob · default | What it changes |
|---|---|
| --dsnpostgresql://postgres:lab@localhost:55804/postgres | Connection string. |
| --keys1,000,000, 10,000, 100, 1 | Values of K. |
| --workers16 | Workers. |
| --seconds15 | Seconds per run. |
Our run
Data table
| Rows K | series | Successful commits/s |
|---|---|---|
| 1M | pessimistic | 3,510 |
| 1M | optimistic | 3,090 |
| 10K | pessimistic | 3,120 |
| 10K | optimistic | 3,420 |
| 100 | pessimistic | 3,230 |
| 100 | optimistic | 2,900 |
| 1 | pessimistic | 1,850 |
| 1 | optimistic | 234 |
16 workers, 15 s
| K | Mode | Attempts/s | Goodput/s | Aborts |
|---|---|---|---|---|
| 1M | pessimistic | 3.51K | 3.51K | 0% |
| 1M | optimistic | 3.09K | 3.09K | 0.002% |
| 10K | pessimistic | 3.12K | 3.12K | 0% |
| 10K | optimistic | 3.42K | 3.42K | 0.074% |
| 100 | pessimistic | 3.23K | 3.23K | 0% |
| 100 | optimistic | 3.15K | 2.9K | 8% |
| 1 | pessimistic | 1.85K | 1.85K | 0% |
| 1 | optimistic | 2.75K | 234 | 91.5% |
K = 1,000,000 pessimistic attempts 3512/s goodput 3512/s aborts 0.0%
K = 1,000,000 optimistic attempts 3092/s goodput 3092/s aborts 0.0%
K = 10,000 pessimistic attempts 3122/s goodput 3122/s aborts 0.0%
K = 10,000 optimistic attempts 3419/s goodput 3416/s aborts 0.1%
K = 100 pessimistic attempts 3233/s goodput 3233/s aborts 0.0%
K = 100 optimistic attempts 3153/s goodput 2901/s aborts 8.0%
K = 1 pessimistic attempts 1852/s goodput 1852/s aborts 0.0%
K = 1 optimistic attempts 2754/s goodput 234/s aborts 91.5%What we found
With a million rows the two were close (3.51K commits per second locking, 3.09K optimistic; the client round trips dominate). At K = 100, optimism aborted 8% of attempts and fell behind (2.9K against 3.23K), a crossover at an abort rate of a few percent, as expected. At K = 1 it attempted 2.75K times per second and committed 234 (91.5% aborted), while locking queued and still committed 1.85K.
Read it
Optimism is a bet that conflicts are rare, and the abort rate tells you whether you are winning it. Measure goodput, not attempts: an optimistic system under contention looks busy and does little. The crossover is around a few percent of aborts; above it, lock.