Dev & EngARTICLE

Why PostgreSQL's max_wal_size is neither a maximum limit nor the size of the WAL

The parameter everyone reads as a space ceiling is actually a checkpoint trigger. Understanding the math behind it avoids I/O storms and recovery that takes longer than it should.

The parameter everyone reads as a space ceiling is actually a checkpoint trigger. Understanding the math behind it avoids I/O storms and recovery that takes longer than it should.

The name lies, the mechanics don't

max_wal_size has existed since PostgreSQL 9.5, when Heikki Linnakangas replaced the old checkpoint_segments. Despite the name, the parameter doesn't define a maximum size for anything you'd measure with du in the pg_wal directory. It is, as the author of the post published on Planet PostgreSQL describes it, a checkpoint trigger denominated in bytes.

The mechanism is simple to state: when the WAL written since the start of the last checkpoint reaches a fixed fraction of this value, the next checkpoint begins, whether or not checkpoint_timeout has elapsed. The default is 1GB, the context is sighup (changes without restarting the server), and the range runs from 2 to 2147483647 megabytes, about two petabytes that, according to the author, nobody has actually tested.

checkpoint_segments, the old parameter, had a default of 3 and triggered a checkpoint every 48 MB of WAL since version 7.1, in 2001. The conversion formula given in the 9.5 release notes was (3 * checkpoint_segments) * 16MB. The new default was already born much higher, which was true and, at the same time, a bar set much too low to clear.

The math the checkpointer actually does

The checkpointer doesn't trigger at max_wal_size. It triggers at max_wal_size / (1 + checkpoint_completion_target), rounded to whole 16 MB segments, because the parameter defines the maximum WAL between the redo point of one checkpoint and the end of the next, and the server keeps writing WAL while a checkpoint spread out over time is in progress.

With the defaults from PostgreSQL 14 onward (checkpoint_completion_target = 0.9), this gives 64 / 1.9 = 33 segments, or 528 MB. The number shows up in every log_checkpoints line:

checkpoint complete: wrote 36828 buffers (56.2%), ... 0 WAL file(s) added, 0 removed, 33 recycled; write=22.929 s, sync=0.150 s, total=23.108 s; ... distance=540676 kB, estimate=540681 kB; ...

distance is the WAL written between the last two checkpoint starts, estimate is the moving average the server uses to decide how many segments to keep, and both land on 33 × 16 MB. The same max_wal_size value has produced three different triggers across versions, without the configured number changing by a single comma.

  • Before 11: the server kept WAL for two checkpoint cycles and divided by 2 + checkpoint_completion_target. With the old default of 0.5, 1GB turned into a checkpoint every 400 MB.
  • From 11 to 13, with the same checkpoint_completion_target = 0.5, the formula started dividing by 1 + completion_target, which raised the interval to 672 MB.
  • From 14 onward, with the completion_target default rising to 0.9, the interval dropped to the 528 MB in the example above.

What tightening it too much costs, in numbers

The author ran the same pgbench workload (scale 50, four clients, shared_buffers at 512 MB, three minutes per run, checkpoint_timeout at 15min, PostgreSQL 18.6) varying only max_wal_size:

ConfigurationtpsWAL writtencheckpointswal_fpi
max_wal_size = 1GB3,7003,815 MB7 (forced)447,963
max_wal_size = 8GB4,1311,080 MB091,335

That's three and a half times more WAL for the same transactions, and the real difference is bigger than the number suggests: the 8GB run still paid, within those three minutes, the full-page image cost of one entire checkpoint, a cost that a fifteen-minute cycle pays only once. The mechanism behind this is full_page_writes: the first change to any page after a checkpoint writes the entire 8 kB page to the WAL, and at 1GB this workload triggered a checkpoint every 22 to 28 seconds, re-imaging hot pages at that frequency.

pg_stat_wal.wal_fpi is the right place to see this on your own server: at 1GB, full-page images were 10% of WAL records and, with wal_compression disabled, about 90% of the bytes written. Every extra byte of WAL is also what gets archived, replicated to each replica, and read by every logical decoding slot, which makes the parameter relevant to any CDC pipeline built on pgoutput or wal2json. And the log didn't stay quiet: the message checkpoints are occurring too frequently (23 seconds apart), with a hint pointing right back at max_wal_size, showed up seven times in the 1GB run.

The two lies in the name

The first lie is that the parameter isn't the size of pg_wal. At the end of a checkpoint, the server removes segments older than the new redo point, but recycles the earliest ones into pre-allocated future segments, keeping between min_wal_size and max_wal_size of WAL from that redo point onward, based on the moving estimate. Under constant write load, pg_wal stays close to max_wal_size, which explains the confusion, but the limit is soft in only one direction: an inactive replication slot, a failing archive_command, or a configured wal_keep_size hold on to already-written segments, and nothing in max_wal_size prevents that (that's the job of max_slot_wal_keep_size and of monitoring the other two).

The limit is also soft in the opposite sense, in a less obvious way: lowering the value doesn't free up space right away. Segments already pre-allocated ahead of the insertion point aren't revisited by a checkpoint. In the author's test, lowering the setting from 4GB to 1GB and running three checkpoints left pg_wal at 2.9 GB, with 184 segments waiting to be written; the value only converged to 1 GB after about 1.9 GB of new WAL, one checkpoint at a time, as each cycle removed old segments instead of recycling them.

The second lie is the word "maximum" itself, which suggests a cost for exceeding the value when the real cost is paid for staying below it.

The real cost is paid by going under.

author of the post on Planet PostgreSQL

After a crash, recovery replays the WAL from the last complete checkpoint, and if the crash happens near the end of the following checkpoint, that's the full trigger distance plus everything written during the checkpoint: one entire max_wal_size. The author measured it: killing the postmaster with 1,080 MB of WAL since the last checkpoint cost 6.0 seconds of redo; with 1,627 MB, 12.5 seconds; and 682 MB of WAL from the 1GB regime, loaded with full-page images, took only 1.5 seconds, because restoring a page image is a copy, while a regular record requires reading the page before applying the change.

That works out to something like 10 to 12 seconds per gigabyte of regular WAL, single-threaded, with the data cached, on the machine used for the test. With max_wal_size = 8GB, the worst case there would be a minute and a half of recovery; with 16GB, three minutes.

What to do with this in practice

The author proposes a tuning procedure instead of a magic number:

  1. Set checkpoint_timeout = 15min and max_wal_size = 8GB and let it run for a week.
  2. Look for checkpoint starting: wal in the log outside of batch load windows.
  3. If it shows up outside of bulk loads, double max_wal_size and repeat the search.
  4. If it only shows up during the nightly import, that's exactly what the parameter exists for; leave it alone.

Anyone with a defined recovery time budget (a contractual RTO, for instance) divides that budget by the seconds per gigabyte measured on their own hardware, and that's the practical ceiling for max_wal_size. The proportion of full-page images changes this math from server to server, so the number the author measured serves as an order of magnitude, not a table to copy.

For those running managed PostgreSQL (RDS, Cloud SQL, self-managed instances on Kubernetes), the practical angle is different: WAL disk is, according to the author, "the cheapest thing in the building." Given the impact on replicas, on archive_command, and on any logical decoding consumer, it's worth treating max_wal_size as an architecture parameter, not as a last-minute fine-tuning knob before a traffic spike.

Translated from the Brazilian Portuguese original · Read the original