How to set up PostgreSQL point-in-time recovery with WAL archiving
Point-in-time recovery needs two things: a base backup from pg_basebackup, and every WAL file written since, copied away by archive_command. To restore, unpack the base backup into an empty data directory, set restore_command and a recovery_target_time, create recovery.signal, and start PostgreSQL: it replays the WAL and stops at that moment. On our test server it stopped just before the commit of an accidental DELETE and brought back all 51,000 rows.
When a nightly pg_dump is not enough
A pg_dump taken at 02:30 restores the database as it was at 02:30. Everything written after that is gone. PostgreSQL also records every change in its write-ahead log (WAL), in 16 MB segment files under pg_wal/. Keep a copy of the data directory plus every segment written since, and you can replay the log to any moment after the copy.
| Nightly pg_dump | Base backup + WAL archive | |
|---|---|---|
| What you can restore | The state at dump time | Any moment since the oldest base backup |
| Data lost with the server | Up to 24 hours | Minutes: archive_timeout plus the off-server copy delay |
| Scope | One database, or one table | The whole cluster only |
| Restore to a newer major version | Yes | No. Same major version only |
| Storage | Small, compressed | Large: full copies plus every WAL segment |
Most servers want both: dumps for single databases and upgrades, PITR for undoing the last hour. WAL replays only onto a physical copy, never onto a pg_dump.
Write an archive_command that cannot lose WAL
PostgreSQL runs archive_command once per completed segment, with %p as its path and %f as its file name. Exit 0 tells PostgreSQL the file is safe to recycle, so the command must exit non-zero on any failure and never overwrite a different file of the same name (two servers sharing one archive). After a crash PostgreSQL may re-send a segment it already archived, so an identical existing file must count as success. The documentation's one-liner, test ! -f /archive/%f && cp %p /archive/%f, fails that last case and never flushes the copy to disk. Create the directories, then this script:
sudo install -d -o postgres -g postgres -m 700 /var/backups/pg /var/backups/pg/wal /var/backups/pg/base#!/usr/bin/env bash
# Called by PostgreSQL as: pg-archive-wal %p %f
set -euo pipefail
ARCHIVE="/var/backups/pg/wal"
src="$1"
dest="$ARCHIVE/$2.gz"
# Already archived (PostgreSQL can retry after a crash): succeed only if identical.
if [ -e "$dest" ]; then
if gzip -dc "$dest" | cmp -s - "$src"; then
exit 0
fi
echo "pg-archive-wal: $dest exists with different contents" >&2
exit 1
fi
# Compress to a temporary name, flush it to disk, then rename into place.
gzip -c "$src" > "$dest.tmp"
sync "$dest.tmp"
mv "$dest.tmp" "$dest"
sync "$ARCHIVE"sudo chmod 755 /usr/local/bin/pg-archive-walA segment closed early is still 16 MB; gzip shrank ours to 16–20 KB, and a busy one to 1.1 MB. Since PostgreSQL 15, archive_library can load a C module instead of a shell command (setting both is an error). Open-source tools such as pgBackRest plug into archive_command and also manage base backups and retention.
Turn on archiving
On Debian and Ubuntu, postgresql.conf includes every .conf file in conf.d, so the settings can live in their own file. Paths here are for PostgreSQL 16; change 16 to your version. On other systems, add the lines to postgresql.conf.
wal_level = replica
archive_mode = on
archive_command = '/usr/local/bin/pg-archive-wal %p %f'
archive_timeout = 60wal_level = replicais the default.minimaldoes not log enough to archive, and the server refusesarchive_modewith it.archive_mode = onstarts the archiver. It needs a restart;archive_commandonly needs a reload.archive_timeout = 60switches segments after 60 seconds if anything was written, so recent transactions reach the archive within a minute. That can mean 1,440 full-size files a day, over 22 GB uncompressed, which is why the script compresses.
sudo systemctl restart postgresql@16-mainForce a switch and check that a segment arrived:
sudo -u postgres psql -c "SELECT pg_switch_wal()"sudo -u postgres psql -x -c "SELECT * FROM pg_stat_archiver"-[ RECORD 1 ]------+------------------------------
archived_count | 1
last_archived_wal | 000000010000000000000001
last_archived_time | 2026-10-03 20:17:40.136289+00
failed_count | 0
last_failed_wal |
last_failed_time |
stats_reset | 2026-10-03 20:16:38.954237+00Take a base backup
Archive first, then back up: replay needs every segment from the moment the base backup starts. pg_basebackup copies the running cluster over a replication connection, which Ubuntu's default pg_hba.conf allows locally.
sudo -u postgres pg_basebackup -D /var/backups/pg/base/2026-10-03_2017 -Ft -z -X stream -P -c fast-Dis the target directory. It must be empty or not exist.-Ftwrites tar files instead of a copy of the directory tree;-zgzips them.-X stream(the default) also saves the WAL written during the backup, inpg_wal.tar.gz, so the backup can start even without the archive.-Pprints progress.-c fastcheckpoints at once instead of waiting for the next scheduled checkpoint.
Our 35 MB test cluster became a 4.8 MB base.tar.gz, a 17 KB pg_wal.tar.gz and a backup_manifest. The archive also gets a backup history file, named after the first segment the backup needs:
sudo -u postgres gzip -dc /var/backups/pg/wal/000000010000000000000003.00000028.backup.gzSTART WAL LOCATION: 0/3000028 (file 000000010000000000000003)
STOP WAL LOCATION: 0/3000100 (file 000000010000000000000003)
CHECKPOINT LOCATION: 0/3000060
BACKUP METHOD: streamed
BACKUP FROM: primary
START TIME: 2026-10-03 20:17:52 UTC
LABEL: pg_basebackup base backup
START TIMELINE: 1
STOP TIME: 2026-10-03 20:17:53 UTC
STOP TIMELINE: 1From PostgreSQL 18, pg_verifybackup checks tar backups against the manifest; -n skips WAL parsing, which tar backups do not support. Version 18 verified our backup of a 16 server, where version 16 refused the tar files. On Debian and Ubuntu, call it by its full path:
sudo -u postgres /usr/lib/postgresql/18/bin/pg_verifybackup -n /var/backups/pg/base/2026-10-03_2017backup successfully verifiedNeither the base backup nor the WAL contains /etc/postgresql, where Debian and Ubuntu keep postgresql.conf and pg_hba.conf. Back that directory up with your file backups.
Find the moment to stop at
Before a risky migration or cleanup, name the current position. Recovery can stop exactly there later:
sudo -u postgres psql -c "SELECT pg_create_restore_point('before_cleanup')" pg_create_restore_point
-------------------------
0/4029C20
(1 row)Without a restore point, use the time just before the mistake. To find it exactly, decompress the segment written around then and list its commits with pg_waldump, using the binary that matches the server's major version:
sudo -u postgres sh -c 'mkdir -p /tmp/walcheck && gunzip -c /var/backups/pg/wal/000000010000000000000004.gz > /tmp/walcheck/000000010000000000000004'sudo -u postgres /usr/lib/postgresql/16/bin/pg_waldump -r Transaction /tmp/walcheck/000000010000000000000004rmgr: Transaction len (rec/tot): 34/ 34, tx: 733, lsn: 0/04029B90, prev 0/04029B50, desc: COMMIT 2026-10-03 20:18:10.660446 UTC
rmgr: Transaction len (rec/tot): 34/ 34, tx: 734, lsn: 0/04627848, prev 0/04627810, desc: COMMIT 2026-10-03 20:18:13.854830 UTC-x 734 shows what transaction 734 did: DELETE records on rel 1663/16384/16386, meaning tablespace, database OID and file node. In that database, SELECT pg_filenode_relation(0, 16386) returned orders. Any target between the two commits, such as 20:18:13, keeps everything except the DELETE.
Restore to a point in time
Stop the server and move the data directory aside rather than deleting it: its pg_wal may hold segments that never reached the archive.
sudo systemctl stop postgresql@16-mainsudo mv /var/lib/postgresql/16/main /var/lib/postgresql/16/main.brokensudo install -d -o postgres -g postgres -m 700 /var/lib/postgresql/16/mainsudo -u postgres tar -xzf /var/backups/pg/base/2026-10-03_2017/base.tar.gz -C /var/lib/postgresql/16/mainLeave pg_wal.tar.gz out; the archive has those segments. Then tell PostgreSQL where the archive is and where to stop:
restore_command = 'gunzip -c /var/backups/pg/wal/%f.gz > %p'
recovery_target_time = '2026-10-03 20:18:13+00'
recovery_target_action = 'pause'sudo -u postgres touch /var/lib/postgresql/16/main/recovery.signalsudo systemctl start postgresql@16-mainsudo tail -n 12 /var/log/postgresql/postgresql-16-main.loggzip: /var/backups/pg/wal/00000002.history.gz: No such file or directory
2026-10-03 20:19:11.250 UTC [1558613] LOG: starting point-in-time recovery to 2026-10-03 20:18:13+00
2026-10-03 20:19:11.251 UTC [1558613] LOG: starting backup recovery with redo LSN 0/3000028, checkpoint LSN 0/3000060, on timeline ID 1
2026-10-03 20:19:11.347 UTC [1558613] LOG: restored log file "000000010000000000000003" from archive
2026-10-03 20:19:11.389 UTC [1558613] LOG: redo starts at 0/3000028
2026-10-03 20:19:11.502 UTC [1558613] LOG: restored log file "000000010000000000000004" from archive
2026-10-03 20:19:11.539 UTC [1558613] LOG: completed backup recovery with redo LSN 0/3000028 and end LSN 0/3000100
2026-10-03 20:19:11.539 UTC [1558613] LOG: consistent recovery state reached at 0/3000100
2026-10-03 20:19:11.539 UTC [1558609] LOG: database system is ready to accept read-only connections
2026-10-03 20:19:11.589 UTC [1558613] LOG: recovery stopping before commit of transaction 734, time 2026-10-03 20:18:13.85483+00
2026-10-03 20:19:11.589 UTC [1558613] LOG: pausing at the end of recovery
2026-10-03 20:19:11.589 UTC [1558613] HINT: Execute pg_wal_replay_resume() to promote.The gzip line is normal: PostgreSQL asks for history files that may not exist. While paused, the server is read-only, so check the data. Ours had all 51,000 rows, including 1,000 written after the base backup:
sudo -u postgres psql -d shop -c "SELECT count(*) FROM orders"If the data is right, end recovery; the log says selected new timeline ID: 2 and archive recovery complete. For a later moment instead, stop the server, move the target later and start again. For an earlier one, start over from the base backup.
sudo -u postgres psql -c "SELECT pg_wal_replay_resume()"sudo rm /etc/postgresql/16/main/conf.d/recovery.confPostgreSQL deletes recovery.signal itself. The new timeline's WAL is archived under new names (00000002…), so the old history stays intact. Take a fresh base backup once you are happy.
| Setting | Stops at |
|---|---|
recovery_target_time | A timestamp. Use a numeric offset such as +00 or a full zone name, not EEST. |
recovery_target_name | A restore point. Ours logged recovery stopping at restore point "before_cleanup". |
recovery_target_xid / recovery_target_lsn | A transaction ID or WAL position from pg_waldump. |
recovery_target = 'immediate' | The earliest consistent point: the end of the base backup. |
| No target | The end of the archive, plus any segments you copy from the old pg_wal. |
Set at most one target. recovery_target_action is pause (default), promote or shutdown.
To rehearse on another machine with the same configuration, add archive_mode = off to the recovery settings, so the rehearsal does not write its own timeline into your real archive.
Keep base backups and WAL in step
WAL is only useful from the start of the oldest base backup you keep. This script takes a backup, checks the tar file reads to the end, keeps the newest two, and deletes archived segments older than the oldest one left:
#!/usr/bin/env bash
# Take a base backup, keep the newest few, and delete WAL that no kept backup needs.
set -euo pipefail
BASE_DIR="/var/backups/pg/base"
WAL_DIR="/var/backups/pg/wal"
KEEP=2
DEST="$BASE_DIR/$(date +%Y-%m-%d_%H%M)"
trap 'rm -rf "$DEST.partial"' EXIT
pg_basebackup -D "$DEST.partial" -Ft -z -X stream -c fast
tar -tzf "$DEST.partial/base.tar.gz" > /dev/null
mv "$DEST.partial" "$DEST"
# Delete all but the newest $KEEP finished backups.
find "$BASE_DIR" -mindepth 1 -maxdepth 1 -type d -name '????-??-??_????' | sort | head -n -"$KEEP" | xargs -r rm -rf
# Delete archived WAL older than the oldest backup that is left.
OLDEST=$(find "$BASE_DIR" -mindepth 1 -maxdepth 1 -type d -name '????-??-??_????' | sort | head -n 1)
FIRST_WAL=$(tar -xzOf "$OLDEST/base.tar.gz" backup_label | sed -n 's/^START WAL LOCATION: .*(file \(.*\))$/\1/p')
pg_archivecleanup -x .gz "$WAL_DIR" "$FIRST_WAL"15 3 * * 0 postgres /usr/local/bin/pg-base-backup >> /var/backups/pg/base-backup.log 2>&1backup_label inside base.tar.gz names the backup's first segment. Weekly backups with KEEP=2 give a recovery window of 7 to 14 days. Storage peaks just after a new backup, before the oldest is deleted: three base backups plus 14 days of WAL. With 6 GB backups and 2 GB of compressed WAL a day, that is 3 × 6 + 14 × 2 = 46 GB. Replaying a week of WAL takes time too; measure it with a test restore.
pg_archivecleanup's last argument is the oldest file to keep; a .backup file name works. -n lists what it would delete:
sudo -u postgres pg_archivecleanup -n -x .gz /var/backups/pg/wal 000000010000000000000003.00000028.backup/var/backups/pg/wal/000000010000000000000001.gz
/var/backups/pg/wal/000000010000000000000002.gzWithout -x .gz, pg_archivecleanup matched none of our compressed files and still exited 0. It keeps the small .backup history files; from PostgreSQL 17, -b removes old ones too.
Ship the archive off the server
An archive on the database's own disk dies with it. Copy /var/backups/pg to object storage every few minutes with rclone or restic, into a bucket on Amazon S3, Backblaze B2 or Cloudflare R2. Your worst-case loss is then archive_timeout plus the copy interval. The archive holds every row ever written, so encrypt it before it leaves the server.
pgBackRest handles WAL archiving, full, differential and incremental backups and point-in-time restores to S3 in one tool: see how to back up PostgreSQL with pgBackRest.
Watch the archiver
A failing archive_command does not stop PostgreSQL. It retries, and WAL piles up in pg_wal until the disk fills and the server shuts down. Alert when failed_count rises:
sudo -u postgres psql -c "SELECT archived_count, failed_count, last_failed_wal, now() - last_archived_time AS since_last_archive FROM pg_stat_archiver"We planted a different file under the next segment's name. The script refused to overwrite it and the log showed why:
pg-archive-wal: /var/backups/pg/wal/000000010000000000000007.gz exists with different contents
2026-10-03 20:20:27.864 UTC [1557319] LOG: archive command failed with exit code 1
2026-10-03 20:20:27.864 UTC [1557319] DETAIL: The failed archive command was: /usr/local/bin/pg-archive-wal pg_wal/000000010000000000000007 000000010000000000000007
2026-10-03 20:20:27.864 UTC [1557319] WARNING: archiving write-ahead log file "000000010000000000000007" failed too many times, will try again laterOnce we removed the file, the segment archived on the next retry. On an idle server last_archived_time stops moving, which is normal. A command killed by a signal, or one that is not found, is not counted in failed_count, so watch the log too.
Incremental backups (PostgreSQL 17 and newer)
Version 17 added incremental base backups, which copy only blocks changed since an earlier backup. Our test server runs 16, so these commands are checked against the PostgreSQL 18 documentation, not run. First turn on WAL summaries; a reload is enough:
summarize_wal = onThen take a full backup in the default plain format, and later an incremental one that names the full backup's manifest:
sudo -u postgres pg_basebackup -D /var/backups/pg/full -c fastsudo -u postgres pg_basebackup -D /var/backups/pg/incr-1 --incremental=/var/backups/pg/full/backup_manifest -c fastsudo -u postgres /usr/lib/postgresql/18/bin/pg_combinebackup /var/backups/pg/full /var/backups/pg/incr-1 -o /var/backups/pg/combinedThe incremental backup fails unless WAL was summarized since the earlier backup started, and summaries are deleted after wal_summary_keep_time (10 days by default). To restore, pg_combinebackup rebuilds a full backup from the chain, oldest first; recovery then works as above. PostgreSQL does not track which backups a chain needs, so never delete one a later incremental depends on.
The full walkthrough, with pg_combinebackup, pg_verifybackup and a weekly chain script: PostgreSQL incremental backups with pg_basebackup.
Common errors
| Error or symptom | Cause and fix |
|---|---|
FATAL: recovery ended before configured recovery target was reached | The target is later than the last archived WAL. Choose an earlier target, or copy unarchived segments from the old pg_wal. |
FATAL: must specify restore_command when standby mode is not enabled | recovery.signal exists but restore_command is not set. |
invalid value for parameter "recovery_target_time" | A time zone abbreviation such as EEST. Use +00 or a full name like Europe/Helsinki. |
| Recovery stops later than the target | The target falls before the base backup finished. PostgreSQL did not refuse: it replayed to the end of the backup and stopped before the next commit. Use an older base backup. |
The server will not start and a recovery.conf exists | That file is from guides for PostgreSQL 11 and older. Since 12, use recovery.signal plus the settings above. |
Frequently asked questions
- Can I restore a single table with point-in-time recovery?
- Not directly; it restores the whole cluster. Restore to a separate server or directory, then copy the table out with pg_dump and load it into production.
- How much data can I lose with WAL archiving?
- Up to archive_timeout, plus however often you copy the archive off the server. With archive_timeout = 60 and a five-minute copy, about six minutes.
- Does point-in-time recovery replace pg_dump?
- No. Physical backups only restore to the same major version and only as a whole cluster. Keep dumps for upgrades and single databases.
How this was checked
The commands were run on Ubuntu 24.04 LTS, PostgreSQL 16.15 on October 3, 2026. Any that need something this test server does not have, such as a second server, a cloud account or another database engine, were checked against the official pages below instead.
Sources, on October 3, 2026:
- PostgreSQL documentation: Continuous Archiving and Point-in-Time Recovery
- PostgreSQL documentation: Write Ahead Log settings (archiving, recovery, recovery target)
- PostgreSQL documentation: pg_basebackup
- PostgreSQL documentation: pg_combinebackup
- PostgreSQL documentation: pg_verifybackup
- PostgreSQL documentation: pg_waldump
- PostgreSQL documentation: pg_archivecleanup
- PostgreSQL documentation: The Cumulative Statistics System (pg_stat_archiver)
- PostgreSQL documentation: System Administration Functions
- PostgreSQL documentation: recovery.conf merged into postgresql.conf
- PostgreSQL 17 release notes
- PostgreSQL 18 release notes
- PostgreSQL documentation: Upgrading a PostgreSQL Cluster