Checkpoint interval versus WAL volume (Postgres)
Run a real systemNeeds Docker, Python
Problem
PostgreSQL writes every change to its write-ahead log before changing the data page, and a checkpoint writes all dirty pages to the data files so that recovery 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
Expect the short interval to produce noticeably more WAL because of full-page images.
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
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)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide 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: 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 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
| Knob · default | What 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
Data table
| Setting | WAL bytes per transaction |
|---|---|
| — | 6,940 |
| — | 3,640 |
| — | 460 |
pgbench, 8 clients, 120 s
| Setting | Transactions | WAL (MB) | Bytes per tx | Full-page images |
|---|---|---|---|---|
| checkpoint every 30 s | 270K | 1,870 | 6.94K | 218K |
| checkpoint every 15 min | 196K | 712 | 3.64K | 78.4K |
| every 30 s, full_page_writes off | 202K | 93 | 460 | 0 |
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 0What 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 amplification. 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 torn pages; 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.