The Sunday full backup of our 2.4 TB cluster had quietly grown into a nine-hour job. It started at 01:00, was still running when the European batch window opened, pushed the read replica into visible lag, and the object-storage invoice for holding four weekly fulls had started to look like a second salary. We had lived with third-party incremental tools before, but when this cluster went to PostgreSQL 17 I wanted the native feature: pg_basebackup --incremental for the daily run and pg_combinebackup to put a full back together. It works, and weeknight backups dropped to roughly 95 GB. It also failed twice on me before it worked — once on WAL summaries that did not exist yet, and once on the assumption that restoring an incremental backup means restoring it, which it does not.
This is the operational write-up I wanted before that first weekend: what the feature actually does, the two prerequisites that bite, and how a "restore" really works when your backup is a chain.
What does PostgreSQL 17 incremental backup actually do?
It takes a full base backup you already have and backs up only the data blocks that changed since it — no more and no less. You hand pg_basebackup the backup_manifest file from the previous backup (full or incremental; every base backup since PostgreSQL 13 writes one), and the server compares the manifest's system identifier and timeline, walks the WAL summaries to find which relation blocks were touched since that backup's checkpoint, and streams only those blocks. The output is a valid-looking backup directory that is deliberately not restorable on its own: it contains only changed blocks plus the bookkeeping pg_combinebackup needs later. The win is exactly what you would expect — our weeknight runs went from 2.4 TB streamed to about 95 GB, and backup duration stopped colliding with the morning batch window. The cost is that the thing on disk is no longer self-sufficient, which changes your restore procedure, your retention math, and your failure modes.
One honest scope note before the details: this feature answers "the nightly full is too big." It does not replace WAL archiving or PITR thinking — the backup strategies guide covers where base backups sit in the bigger picture — and it does not exempt you from restore drills, as the PITR restore drill notes argue at length. It changes the economics of the base-backup layer, and that is all.
Why did the first incremental backup fail with missing WAL summaries?
Because incremental backup is not block-diffing at read time — it depends on WAL summary files, and those only exist if the WAL summarizer has been running. PostgreSQL 17 added a background process, the WAL summarizer, which reads WAL as it is produced and writes compact per-segment lists of modified blocks into pg_wal/summaries. The controlling knob is summarize_wal, it defaults to off, and enabling it requires a restart, which on a production primary means a maintenance window, which means our first incremental attempt ran against a cluster where I had enabled the setting on Thursday and taken the "baseline" full backup the previous Sunday. The incremental run needs summaries covering everything since the baseline's start checkpoint; summaries from before the feature was switched on do not exist and can never be reconstructed, so pg_basebackup refused. The ordering is load-bearing and I now have it taped to the runbook: enable summarize_wal, restart, let the summarizer run, then take a fresh full backup as the baseline, and only then start incrementals against that manifest.
-- Run on the primary before scheduling anything
SELECT name, setting FROM pg_settings
WHERE name IN ('summarize_wal', 'wal_summary_keep_time');
-- Are summaries actually being produced, and how far back do they reach?
SELECT tli, start_lsn, end_lsn
FROM pg_available_wal_summaries()
ORDER BY start_lsn
LIMIT 5;
The second knob is wal_summary_keep_time, default ten days, which bounds how far back an incremental chain can reach. If your full backup is weekly and a cron failure lets the chain stretch past the retention window, the next incremental does not degrade into a full — it errors, because the summaries it needs have been vacuumed away. Set the retention longer than your worst-case gap between fulls with slack, and alert on the failure instead of discovering the gap at restore time. Storage for the summaries themselves is modest, but they are on the WAL path, so they belong in the same disk-headroom math as everything else under pg_wal, the same math the WAL monitoring notes walk through.
How do you restore from a chain of incrementals?
You do not restore an incremental — you synthesize a full backup first, with pg_combinebackup, and restore that. The tool takes the full backup directory followed by every incremental in order, plus an output directory, and reconstructs a complete, consistent data directory by overlaying each incremental's changed blocks onto the previous state. That synthesized directory is what you start PostgreSQL on, point recovery at WAL for, or clone from. Two operational consequences fall out of this. First, restore time now includes combine time, and combine time scales with the number of incrementals in the chain and the size of the full — our seven-day chain added about forty minutes to the drill, which pushed us to a mid-week full rather than a Sunday-only one. Second, the chain is only as strong as its weakest link: lose or corrupt any one incremental, and every later incremental in the chain is dead weight, which is why pg_verifybackup on each manifest matters more here than it did with independent fulls.
-- Daily incremental: baseline manifest from the previous backup
-- (run from shell, shown here as the exact commands we cron)
-- pg_basebackup --incremental=/backups/last/backup_manifest -- --pgdata=/backups/inc-$(date +%F) --format=plain
-- Verify every link in the chain as it lands
-- pg_verifybackup /backups/inc-2026-08-06
-- At restore time, combine full + incrementals oldest to newest
-- pg_combinebackup /backups/full-2026-08-02 -- /backups/inc-2026-08-03 /backups/inc-2026-08-04 -- -o /restore/synthesized-full
The drills rule survives unchanged, it just gets longer: once a month we combine the current chain, boot the synthesized directory, and run the same row-count and checksum spot checks as with any other restore. The first drill surfaced a genuinely embarrassing gap — the manifest of the previous backup has to be reachable by the backup job, and ours lived on the volume that had already expired out of object storage. Manifests are now copied next to the job config, not just inside the backup directory.
What did incremental backup change about our retention policy?
It made retention a chain-length decision instead of a capacity decision. With independent fulls, retention is "how many copies can we afford." With a weekly full plus daily incrementals, every restore older than the newest full depends on the entire chain back to that full, so the safe unit of retention is "one full plus all of its incrementals," kept until the next full has been verified. We hold two generations: the current chain, and the previous complete chain until the new full passes pg_verifybackup and a combine drill. The storage math worked out to roughly one full plus a third, versus the four fulls we were holding before — the bill stopped being a topic in cost reviews, and weeknight backup traffic fell by more than ninety percent. The one regression to watch: an incremental still reads every page of every relation to decide what changed, so I/O on the source does not drop nearly as much as network and storage do, and on our busiest night the read load still needed the same scheduling care as before.
Watching backup health with MonPG
The failure modes that hurt with incremental chains are all quiet ones: the WAL summarizer falling behind, wal_summary_keep_time trimming toward your chain's start, replica lag while the backup reads every page. MonPG monitors PostgreSQL in production today, and its PostgreSQL monitoring puts WAL generation, archive health, and replication lag on one timeline, so the night the summarizer stalls shows up as a bent curve with an alert on it rather than as a failed cron job someone reads at 09:00. Chains make restores slower and dependencies longer; that is exactly when you want the surrounding telemetry to be boring, complete, and already on a screen.