Savepoint cliff in Postgres (more than 64 subtransactions)
Run a real systemNeeds Docker, Python
Problem
In PostgreSQL every savepoint (and every PL/pgSQL EXCEPTION block) starts a subtransaction. Each backend caches up to 64 of its open subtransaction IDs in shared memory; beyond that the cache overflows, and every other session checking whether a row version is visible must look the transaction up in the subtransaction log instead. A mechanism that is free in the writing session becomes a cost for every reader.
Run a long transaction that creates many subtransactions in a loop (an EXCEPTION block around each UPDATE) and leave it open, while pgbench runs a read-only query on the same rows with 8 clients for 20 seconds. Compare 0, 50, 64, 65 and 200 subtransactions and report the ratio of read throughput.
The question. Does read throughput change when the open transaction passes 64 subtransactions?
Predict
With 50 subtransactions in the open transaction, the reads run at normal speed; with more than 64 the reads slow down noticeably, because other sessions must consult the subtransaction structure. Exact numbers depend on the version and the machine; report the ratio.
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
savepoint-cliff-in-postgres-more-than.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 """The savepoint cliff in PostgreSQL: more than 64 subtransactions. Session A opens a transaction that creates N subtransactions (an EXCEPTION block per UPDATE in a loop) and leaves it open. Meanwhile pgbench runs a read-only query on the same rows with 8 clients for 20 seconds. N = 0, 50, 64, 65 and 200: read throughput against N. Needs the PostgreSQL service from the compose file (port 55806) and the docker CLI (pgbench runs inside the container). """ import argparse, json, re, subprocess import psycopg p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55806/postgres", help="Connection string.") p.add_argument("--container", default="savepoint-cliff-in-postgres-more-than-postgres-1", help="PostgreSQL container (for pgbench).") p.add_argument("--subxacts", default="0,50,64,65,200", help="Subtransactions in the open transaction.") p.add_argument("--seconds", type=int, default=20, help="pgbench duration.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() admin = psycopg.connect(args.dsn, autocommit=True) admin.execute("DROP TABLE IF EXISTS wallets"); admin.execute("CREATE TABLE wallets (owner text PRIMARY KEY, balance int)") admin.execute("INSERT INTO wallets SELECT 'w' || g, 0 FROM generate_series(1, 1000) g"); admin.execute("INSERT INTO wallets VALUES ('anna', 0)") subprocess.run(["docker", "exec", args.container, "sh", "-c", "echo \"SELECT sum(balance) FROM wallets WHERE owner IN ('anna', 'w1', 'w2');\" > /tmp/select_wallets.sql"], check=True) rows = [] for n in map(int, args.subxacts.split(",")): a = psycopg.connect(args.dsn); a.execute("BEGIN") a.execute("UPDATE wallets SET balance = balance + 1 WHERE owner = 'w1'") # the top-level transaction writes too if n: a.execute(f"""DO $$ BEGIN FOR i IN 1..{n} LOOP BEGIN UPDATE wallets SET balance = balance + 1 WHERE owner = 'anna'; EXCEPTION WHEN OTHERS THEN NULL; END; END LOOP; END $$""") # each EXCEPTION block is a subtransaction out = subprocess.run(["docker", "exec", args.container, "pgbench", "-U", "postgres", "-n", "-T", str(args.seconds), "-c", "8", "-j", "4", "-f", "/tmp/select_wallets.sql", "postgres"], capture_output=True, text=True) tps = float(re.search(r"tps = ([\d.]+)", out.stdout).group(1)) rows.append({"subtransactions": n, "read_tps": tps}); a.rollback(); a.close() base = rows[0]["read_tps"] for r in rows: r["vs_none"] = r["read_tps"] / base for r in rows: print(f"{r['subtransactions']:>4} subtransactions open: {r['read_tps']:9.0f} reads/s ({r['vs_none']:.2f}x of none)") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"subxacts": 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
savepoint-cliff-in-postgres-more-than.compose.yml # PostgreSQL for the savepoint-cliff-in-postgres-more-than lab. services: postgres: image: postgres:17.6 command: ["postgres", "-c", "max_connections=200", "-c", "shared_buffers=256MB"] environment: POSTGRES_PASSWORD: lab ports: ["55806: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 savepoint-cliff-in-postgres-more-than.compose.yml up -d --wait uv run savepoint-cliff-in-postgres-more-than.py uv run savepoint-cliff-in-postgres-more-than.py --help # every knob docker compose -f savepoint-cliff-in-postgres-more-than.compose.yml down -v
| Knob · default | What it changes |
|---|---|
| --dsnpostgresql://postgres:lab@localhost:55806/postgres | Connection string. |
| --containersavepoint-cliff-in-postgres-more-than-postgres-1 | PostgreSQL container (for pgbench). |
| --subxacts0, 50, 64, 65, 200 | Subtransactions in the open transaction. |
| --seconds20 | pgbench duration. |
Our run
Data table
| Subtransactions in the open transaction | Reads/s |
|---|---|
| 0 | 86.4K |
| 50 | 84.9K |
| 64 | 80.4K |
| 65 | 73.7K |
| 200 | 64.6K |
0 subtransactions open: 86389 reads/s (1.00x of none)
50 subtransactions open: 84880 reads/s (0.98x of none)
64 subtransactions open: 80371 reads/s (0.93x of none)
65 subtransactions open: 73732 reads/s (0.85x of none)
200 subtransactions open: 64628 reads/s (0.75x of none)What we found
With no subtransactions the readers ran at 86.4K queries per second; with 50, 84.9K (0.983×); with 64, 80.4K (0.93×); with 65, 73.7K (0.853×); and with 200, 64.6K (0.748×).
Read it
Savepoints are cheap for the session that takes them and can be expensive for everyone else, once a transaction holds more than the cache. Look for code that creates subtransactions in loops (exception handlers in procedures, ORMs that wrap each statement in a savepoint) inside long transactions.