Skip to content
About
Main content

Checkpoint interval versus WAL volume (Postgres)

Run a real systemNeeds Docker, Python

Problem

PostgreSQL writes every change to its before changing the data page, and a writes all to the data files so that can start from it. The first time a page is changed after a checkpoint, the log records a full copy of the page (a full-page image, about 8 KB) instead of the small change, so that a page torn by a crash mid-write can be restored. The checkpoint interval therefore sets two things at once: how much log recovery must replay, and how many full-page images the log carries. Short intervals give fast recovery and a much larger log.

Run pgbench -i -s 50, then pgbench -c 8 -T 120 with checkpoint_timeout = 30s, full_page_writes = on, and look at WAL bytes generated (pg_stat_wal). Repeat with checkpoint_timeout = 15min (and a large max_wal_size). Then turn full_page_writes off only on a throwaway instance and see the effect.

The question. How much more WAL does a 30-second checkpoint interval write than a 15-minute one, and how much of it is full-page images?


Predict

With a checkpoint every 30 seconds, how much of the WAL is full-page images rather than changes?

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.

    checkpoint-interval-versus-wal-volume-postgres.py72 linesDownload
    View the code
    checkpoint-interval-versus-wal-volume-postgres.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
    """Checkpoint interval against WAL volume in PostgreSQL.
    
    A pgbench-style TPC-B workload (8 clients updating accounts, tellers and
    branches) for a fixed time with checkpoint_timeout = 30 s, then 15 min, then
    30 s with full_page_writes off: WAL bytes generated, and WAL bytes per
    transaction. The load is driven from Python so it needs only the database.
    Needs the PostgreSQL service from the compose file (port 55500).
    """
    import argparse, json, random, threading, time
    import psycopg
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55500/postgres", help="Connection string.")
    p.add_argument("--scale", type=int, default=50, help="pgbench scale (100,000 accounts per unit).")
    p.add_argument("--clients", type=int, default=8, help="Client threads.")
    p.add_argument("--seconds", type=int, default=120, help="Seconds per setting.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    admin = psycopg.connect(args.dsn, autocommit=True)
    A = args.scale * 100_000
    admin.execute("DROP TABLE IF EXISTS accounts, tellers, branches, history")
    admin.execute("CREATE TABLE branches (bid int PRIMARY KEY, bbalance int, filler char(88))")
    admin.execute("CREATE TABLE tellers (tid int PRIMARY KEY, bid int, tbalance int, filler char(84))")
    admin.execute("CREATE TABLE accounts (aid int PRIMARY KEY, bid int, abalance int, filler char(84)) WITH (fillfactor = 100)")
    admin.execute("CREATE TABLE history (tid int, bid int, aid int, delta int, mtime timestamp, filler char(22))")
    admin.execute(f"INSERT INTO branches SELECT b, 0, '' FROM generate_series(1, {args.scale}) b")
    admin.execute(f"INSERT INTO tellers SELECT t, (t - 1) / 10 + 1, 0, '' FROM generate_series(1, {args.scale * 10}) t")
    admin.execute(f"INSERT INTO accounts SELECT a, (a - 1) / 100000 + 1, 0, '' FROM generate_series(1, {A}) a")
    admin.execute("VACUUM ANALYZE")
    
    def workload():
        stop, count = threading.Event(), [0]
        def client():
            con = psycopg.connect(args.dsn, autocommit=False); rnd = random.Random()
            while not stop.is_set():
                aid, tid, bid, d = rnd.randint(1, A), rnd.randint(1, args.scale * 10), rnd.randint(1, args.scale), rnd.randint(-5000, 5000)
                con.execute("UPDATE accounts SET abalance = abalance + %s WHERE aid = %s", (d, aid))
                con.execute("UPDATE tellers SET tbalance = tbalance + %s WHERE tid = %s", (d, tid))
                con.execute("UPDATE branches SET bbalance = bbalance + %s WHERE bid = %s", (d, bid))
                con.execute("INSERT INTO history VALUES (%s, %s, %s, %s, now(), '')", (tid, bid, aid, d))
                con.commit(); count[0] += 1
            con.close()
        ts = [threading.Thread(target=client) for _ in range(args.clients)]
        for t in ts: t.start()
        time.sleep(args.seconds); stop.set()
        for t in ts: t.join()
        return count[0]
    
    rows = []
    for label, timeout, fpw in (("checkpoint every 30 s", "30s", "on"), ("checkpoint every 15 min", "15min", "on"),
                                ("every 30 s, full_page_writes off", "30s", "off")):
        admin.execute(f"ALTER SYSTEM SET checkpoint_timeout = '{timeout}'"); admin.execute(f"ALTER SYSTEM SET full_page_writes = {fpw}")
        admin.execute("SELECT pg_reload_conf()"); admin.execute("CHECKPOINT")
        lsn0 = admin.execute("SELECT pg_current_wal_lsn()").fetchone()[0]
        fpi0 = admin.execute("SELECT wal_fpi FROM pg_stat_wal").fetchone()[0]
        tx = workload()
        wal = float(admin.execute("SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), %s)", (lsn0,)).fetchone()[0])
        fpi = admin.execute("SELECT wal_fpi FROM pg_stat_wal").fetchone()[0] - fpi0
        rows.append({"setting": label, "transactions": tx, "wal_mb": wal / 1e6, "wal_bytes_per_tx": wal / tx, "full_page_images": fpi})
        print(f"{label:<34} {tx:>8,} tx   WAL {wal / 1e6:8.1f} MB   {wal / tx:7.0f} bytes per tx   full-page images {fpi:,}")
    admin.execute("ALTER SYSTEM RESET checkpoint_timeout"); admin.execute("ALTER SYSTEM RESET full_page_writes"); admin.execute("SELECT pg_reload_conf()")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"settings": rows}}, f)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    checkpoint-interval-versus-wal-volume-postgres.compose.yml13 linesDownload
    View the code
    checkpoint-interval-versus-wal-volume-postgres.compose.yml
    # PostgreSQL for the checkpoint-interval lab.
    services:
      postgres:
        image: postgres:17.6
        environment:
          POSTGRES_PASSWORD: lab
        command: ["postgres", "-c", "max_wal_size=8GB", "-c", "shared_buffers=256MB"]
        ports: ["55500: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 checkpoint-interval-versus-wal-volume-postgres.compose.yml up -d --wait
    uv run checkpoint-interval-versus-wal-volume-postgres.py
    uv run checkpoint-interval-versus-wal-volume-postgres.py --help     # every knob
    docker compose -f checkpoint-interval-versus-wal-volume-postgres.compose.yml down -v
Knobs
Knob · defaultWhat it changes
--dsnpostgresql://postgres:lab@localhost:55500/postgres Connection string.
--scale50 pgbench scale (100,000 accounts per unit).
--clients8 Client threads.
--seconds120 Seconds per setting.

Our run

WAL bytes per transactionApple M2 · 8 cores · 8 GB
Data table
WAL bytes per transaction
SettingWAL bytes per transaction
—6,940
—3,640
—460
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · postgres:17.6 · Docker 28.5.1 · run 2026-10-20

pgbench, 8 clients, 120 s

pgbench, 8 clients, 120 s
SettingTransactionsWAL (MB)Bytes per txFull-page images
checkpoint every 30 s270K1,8706.94K218K
checkpoint every 15 min196K7123.64K78.4K
every 30 s, full_page_writes off202K934600
Output · uv run checkpoint-interval-versus-wal-volume-postgres.py
checkpoint every 30 s               269,898 tx   WAL   1874.0 MB      6943 bytes per tx   full-page images 218,404
checkpoint every 15 min             195,572 tx   WAL    712.1 MB      3641 bytes per tx   full-page images 78,353
every 30 s, full_page_writes off    202,096 tx   WAL     93.0 MB       460 bytes per tx   full-page images 0

What we found

With a checkpoint every 30 seconds, each transaction wrote 6.94K bytes of WAL; with one every 15 minutes, 3.64K. The difference was full-page images: 218K against 78.4K. With full-page writes off, a transaction wrote only 460 bytes: almost all of the log was page images, not changes. Within each run, the 15-minute interval still had full-page images, because 120 seconds of pgbench touches most pages for the first time since the run's start.


Read it

The checkpoint interval controls both recovery time and write . Shorter intervals bound how much log a crash must replay, and pay for it with page images that multiply the log, the replication traffic and the archive. Turning full-page writes off removes the images and the protection against ; it is safe only on storage that guarantees atomic 8 KB writes.

On a write-heavy system, the WAL volume is a cost you can tune, and the tuning is a recovery-time objective in disguise.