Skip to content
About
Main content

Pessimistic versus optimistic crossover

Run a real systemNeeds Docker, Python

Problem

Pessimistic concurrency 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 (successful ) 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 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

When all 16 workers want the same row, what share of optimistic attempts abort?

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.

    pessimistic-versus-optimistic-crossover.py58 linesDownload
    View 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)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    pessimistic-versus-optimistic-crossover.compose.yml13 linesDownload
    View 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: 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 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
Knobs
Knob · defaultWhat 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

Goodput against the number of rowsApple M2 · 8 cores · 8 GB
pessimisticoptimistic
Data table
Goodput against the number of rows
Rows KseriesSuccessful commits/s
1Mpessimistic3,510
1Moptimistic3,090
10Kpessimistic3,120
10Koptimistic3,420
100pessimistic3,230
100optimistic2,900
1pessimistic1,850
1optimistic234
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · postgres:17.6 · Docker 28.5.1 · run 2026-10-20

16 workers, 15 s

16 workers, 15 s
KModeAttempts/sGoodput/sAborts
1Mpessimistic3.51K3.51K0%
1Moptimistic3.09K3.09K0.002%
10Kpessimistic3.12K3.12K0%
10Koptimistic3.42K3.42K0.074%
100pessimistic3.23K3.23K0%
100optimistic3.15K2.9K8%
1pessimistic1.85K1.85K0%
1optimistic2.75K23491.5%
Output · uv run pessimistic-versus-optimistic-crossover.py
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.