Skip to content
About
Main content

Savepoint cliff in Postgres (more than 64 subtransactions)

Run a real systemNeeds Docker, Python

Problem

In PostgreSQL every (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 .

The question. Does read throughput change when the open transaction passes 64 subtransactions?


Predict

With 200 subtransactions open, past the 64 that fit in the cache, how fast do the readers run against none?

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.

    savepoint-cliff-in-postgres-more-than.py50 linesDownload
    View 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)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    savepoint-cliff-in-postgres-more-than.compose.yml13 linesDownload
    View 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: 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 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
Knobs
Knob · defaultWhat 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

Read throughput against open subtransactionsApple M2 · 8 cores · 8 GB
Data table
Read throughput against open subtransactions
Subtransactions in the open transactionReads/s
086.4K
5084.9K
6480.4K
6573.7K
20064.6K
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · postgres:17.6 · Docker 28.5.1 · run 2026-10-20
Output · uv run savepoint-cliff-in-postgres-more-than.py
   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.