Early in my PostgreSQL years I inherited a primary that generated several times more WAL than its write rate seemed to justify. The team blamed replication and considered raising network throughput; the actual lever was checkpoint spacing and one compression setting. Full page writes are one of those subsystems that are invisible until you look at WAL volume, and then they explain half of it. This is the mental model I use now: what FPIs are, why checkpoints control them, how wal_compression shrinks them, and how to measure all of it before touching anything.
The torn-page problem full_page_writes solves
PostgreSQL data pages are 8KB. Operating systems and disks do not guarantee an 8KB write lands atomically; a crash mid-write can leave a page half old and half new — torn. WAL replay normally fixes a page by applying changes on top of a consistent base image, but if the base image itself is corrupt, replaying a few bytes of delta onto garbage produces different garbage. That is the failure full_page_writes exists for.
With full_page_writes on — the default, and correct, setting — the first modification to each page after a checkpoint writes the entire page image into WAL, not just the delta. Recovery then starts from a known-good page image and reapplies subsequent deltas. Later modifications of the same page before the next checkpoint only log deltas. The invariant to hold in your head: per page, per checkpoint cycle, the first touch is expensive and the rest are cheap.
Why checkpoint frequency sets your WAL volume
That invariant has a direct operational consequence. Checkpoints reset the "first touch" clock for every page. Checkpoints every five minutes mean every hot page logs a full image up to twelve times an hour; checkpoints every thirty minutes mean twice an hour. On write-heavy workloads with a hot working set — counters, queue tables, frequently updated index roots — FPIs dominate WAL, and the single biggest dial on WAL volume is checkpoint spacing: checkpoint_timeout and, more importantly, max_wal_size, which usually triggers checkpoints long before the timeout does.
-- Are checkpoints time-driven (good) or size-driven (check your spacing)?
-- PostgreSQL 16, in pg_stat_bgwriter:
SELECT checkpoints_timed,
checkpoints_req,
checkpoint_write_time,
checkpoint_sync_time,
buffers_checkpoint
FROM pg_stat_bgwriter;
-- PostgreSQL 17 moved checkpoint statistics to pg_stat_checkpointer:
SELECT num_timed,
num_requested,
write_time,
sync_time,
buffers_written
FROM pg_stat_checkpointer;
-- If requested checkpoints are a large share, raise max_wal_size:
SHOW max_wal_size;
SHOW checkpoint_timeout;
SHOW full_page_writes;
A healthy system sees mostly timed checkpoints. When requested checkpoints dominate, checkpoints are firing because WAL filled max_wal_size, which means more frequent checkpoint cycles, which means more FPIs, which means WAL grows faster — the feedback loop is literal. Raising max_wal_size breaks the loop and usually reduces total WAL generated, at the cost of more WAL to replay after a crash and more disk for pg_wal. It is a real trade, but on modern hardware the balance point is almost always a larger max_wal_size than the default. There is more on this interaction in our WAL and checkpoint tuning article.
wal_compression: shrinking the FPIs you cannot avoid
Spacing checkpoints reduces how often FPIs happen; wal_compression reduces how big each one is. The setting compresses full-page images in WAL. In PostgreSQL 16 and 17 the choices are off, pglz (the built-in algorithm, historically the only option), lz4, and zstd — the latter two require the server built with the corresponding libraries, which most current packages are. lz4 is fast and light on CPU; zstd compresses better and costs more; pglz is the legacy built-in algorithm, and the backward-compatible value on still maps to it. The out-of-box default is off.
-- Check what your build supports, then enable:
SHOW wal_compression;
ALTER SYSTEM SET wal_compression = 'lz4';
SELECT pg_reload_conf();
-- Or zstd, if you want ratio over speed — there is no
-- WAL-specific level knob in 16 or 17, just the algorithm choice:
ALTER SYSTEM SET wal_compression = 'zstd';
On FPI-heavy workloads, enabling wal_compression commonly cuts WAL volume substantially — the pages that get logged whole are exactly the pages full of compressible row data. The cost is CPU on the writer path, both on the primary during WAL insert and on replicas during recovery prefetch/apply. My rule: if WAL volume, archive bandwidth, or replica catch-up time is a live concern, turn on lz4 and measure; only reach for zstd when you have CPU to spare and the extra ratio pays for storage or cross-region traffic.
Measuring WAL before and after
Do not tune this blind. PostgreSQL gives you exact instruments. pg_stat_wal (PG14+) counts WAL generation server-wide, and pg_current_wal_lsn plus pg_wal_lsn_diff measure the raw rate between two observations:
SELECT wal_records,
wal_fpi,
pg_size_pretty(wal_bytes) AS wal_bytes,
wal_buffers_full,
wal_write_time,
wal_sync_time
FROM pg_stat_wal;
-- Rate over a 60-second window:
SELECT pg_current_wal_lsn() AS start_lsn;
-- wait 60 seconds, then:
SELECT pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), '4A/2B03F910')
) AS wal_per_minute;
Two numbers tell the story. wal_fpi counts full-page images and wal_records counts records — one record can carry several images, so treat the ratio as images per record and watch its direction rather than as an exact fraction; wal_bytes trended over time is the volume you are trying to reduce. Capture both before changing checkpoint spacing or compression, then again after — FPI count should drop with wider checkpoint spacing, and bytes should drop with compression. If wal_buffers_full keeps climbing between snapshots — read it as a rate since stats_reset, because the counter is cumulative — that is a different problem (wal_buffers too small for the burst pattern) and worth fixing on its own.
The temptation you must resist
Every so often someone proposes setting full_page_writes=off to kill WAL volume. Do not do this on any storage that can tear an 8KB write, which is to say effectively all storage. The setting exists for filesystems and devices that genuinely guarantee atomic page writes (certain ZFS and Fusion-io style setups), and unless you can name your stack's atomicity guarantee from its documentation, you do not have one. The failure mode is not immediate corruption; it is discovering after a crash that recovery cannot proceed. If WAL volume is the problem, spacing and compression are the honest levers.
WAL volume, archiving, and replicas
The reason WAL volume deserves its own section: it is not just disk usage in pg_wal. Every byte generated on the primary is also shipped to streaming replicas, written into your WAL archive by archive_command or your backup tool, and replayed during crash recovery and point-in-time restores. Doubling WAL volume doubles archive storage and network traffic, lengthens replica catch-up after any lag event, and stretches the recovery window between checkpoints. That is why I treat FPI tuning as a replication-and-recovery change, not a storage tweak.
On the replica side, compressed full-page images help indirectly: less WAL means less to ship and less to apply, and recovery I/O on the standby drops along with it. The one place to be careful is a standby that is already CPU-bound on apply; adding zstd decompression on top can tip it over, which is another vote for lz4 as the default choice unless you have measured otherwise.
One more source of surprise: replication slots force the primary to retain WAL in pg_wal until the slot consumes it, no matter how efficiently you generate it. A forgotten slot from a retired logical-subscription experiment will fill pg_wal all by itself, and it will do so faster on a checkpoint-misconfigured system. FPI tuning shrinks how fast WAL grows; slot hygiene decides how much of it must stay around. Both belong on the same dashboard, watched with the same seriousness as disk space.
How MonPG keeps WAL boring
Every WAL tuning change I have made earned its keep only because I could prove the before-and-after. MonPG monitors PostgreSQL today, and it puts WAL generation rate, checkpoint timing, and replication lag on one timeline, so raising max_wal_size or enabling wal_compression shows up as a measurable shift instead of two pg_stat_wal screenshots you have to diff by eye. The PostgreSQL monitoring pages describe the metrics baseline, and you will find more WAL write-ups on the MonPG blog. Measure, change one lever, measure again — that loop is the entire discipline here.