Reported symptoms
Write latency on the primary spikes from 3 ms to 400 ms roughly every ninety seconds during the nightly bulk load. Each spike lasts five to fifteen seconds, and the pattern repeats for the whole three-hour window.
Read latency is unaffected. CPU is under 30 percent. Disk write throughput shows a matching sawtooth — idle between spikes, saturated during them.
The load takes three hours. The same volume loads in forty minutes on staging, which has identical hardware and an identical PostgreSQL version.
An engineer has proposed lowering checkpoint_completion_target so that
checkpoints finish faster.
Evidence provided
$ grep 'occurring too frequently' /var/log/postgresql/postgresql-18-main.log | tail -32026-08-28 01:34:59.356 UTC [9080] LOG: checkpoints are occurring too frequently (0 seconds apart)
2026-08-28 01:34:59.668 UTC [9080] LOG: checkpoints are occurring too frequently (0 seconds apart)
2026-08-28 01:34:59.984 UTC [9080] LOG: checkpoints are occurring too frequently (0 seconds apart)$ grep 'checkpoint starting' /var/log/postgresql/postgresql-18-main.log | tail -42026-08-28 01:34:59.091 UTC [9080] LOG: checkpoint starting: wal
2026-08-28 01:34:59.356 UTC [9080] LOG: checkpoint starting: wal
2026-08-28 01:34:59.668 UTC [9080] LOG: checkpoint starting: wal
2026-08-28 01:34:59.984 UTC [9080] LOG: checkpoint starting: wal$ grep 'checkpoint complete' /var/log/postgresql/postgresql-18-main.log | tail -12026-08-28 01:34:59.356 UTC [9080] LOG: checkpoint complete: wrote 6573 buffers (40.1%), wrote 1 SLRU buffers; 0 WAL file(s) added, 4 removed, 0 recycled; write=0.215 s, sync=0.029 s, total=0.266 s; sync files=4, longest=0.025 s, average=0.008 s; distance=59186 kB, estimate=143962 kB; lsn=0/F762F898, redo lsn=0/F421D818num_requested rose by thirteen in a few seconds while num_timed
did not move.
max_wal_size in production is 96MB. On staging it is 4GB. The
production value was set three years ago when the cluster was small.
Rerunning the identical workload with max_wal_size raised produced no
WAL-driven checkpoints at all.
Work the evidence before reading on
checkpoint starting: walversuscheckpoint starting: time— what is the difference, and why does it matter to latency?distance=59186 kBagainstmax_wal_size = 96MB. What does that ratio tell you?- Staging is forty minutes and production is three hours on identical hardware. Where would you look first?
- Would lowering
checkpoint_completion_targethelp?
Root cause
Two kinds of checkpoint, and only one of them is gentle
The value was a leftover
96 MB was chosen three years ago for a small cluster and nothing revisited it as the workload grew. Staging, at 4 GB, ran the identical workload with no WAL-driven checkpoints at all — same hardware, same version, same data, forty minutes against three hours.
The proposed fix points the wrong way
Resolution
Read the log lines first. They carry the trigger, the volume and the cost:
grep -E 'checkpoint (starting|complete)|occurring too frequently' \
/var/log/postgresql/postgresql-18-main.log | tail -40
Confirm from the counters, which separate the two triggers:
SELECT num_timed, num_requested, num_done,
write_time, sync_time, buffers_written, stats_reset
FROM pg_stat_checkpointer;
num_requested climbing while num_timed stands still is the signature.
On a healthy cluster the large majority of checkpoints are timed.
Raise max_wal_size so checkpoints are driven by time. It is reloadable:
ALTER SYSTEM SET max_wal_size = '4GB';
SELECT pg_reload_conf();
Size it from the measured WAL generation rate — enough to cover
checkpoint_timeout at peak write rate, with margin. Undersizing produces
this incident; oversizing costs disk and lengthens crash recovery, so
choose deliberately rather than picking a large round number.
Leave checkpoint_completion_target at 0.9.
Verification
checkpoints are occurring too frequently stops appearing. Not less
often — at all.
Checkpoint starts are time rather than wal:
grep -c 'checkpoint starting: wal' /var/log/postgresql/postgresql-18-main.log
grep -c 'checkpoint starting: time' /var/log/postgresql/postgresql-18-main.log
num_requested stops climbing during the load window. Reset the
statistics first, so the comparison is about tonight rather than all
history:
SELECT pg_stat_reset_shared('checkpointer');
-- run the load, then:
SELECT num_timed, num_requested, num_done, write_time, sync_time FROM pg_stat_checkpointer;
distance in the completion lines sits well below max_wal_size.
The write-latency sawtooth is gone and the load completes in a time comparable to staging. That comparison is the strongest check available, because it isolates configuration from hardware.
pg_wal stays within the new expectation and the WAL volume retains
headroom.
Prevention
Alert on checkpoints are occurring too frequently. One of the
highest-value log alerts a cluster can have, and it costs one grep.
Alert on the ratio of requested to timed checkpoints, which moves long before latency does:
SELECT num_timed, num_requested,
round(100.0 * num_requested / nullif(num_timed + num_requested, 0), 1) AS pct_requested
FROM pg_stat_checkpointer;
Keep log_checkpoints on. The completion lines are the primary
instrument for this class of problem; do not disable them to reduce log
volume.
Revisit max_wal_size when the workload changes. A value chosen for a
small cluster three years ago is not a value, it is a leftover.
Diff pg_settings between production and staging when they behave
differently. A five-minute check that would have ended this
immediately:
SELECT name, setting FROM pg_settings WHERE source <> 'default' ORDER BY name;
Do not lower checkpoint_completion_target in response to checkpoint
pain.
Size the WAL volume for max_wal_size plus retention.