OnCallReady

Lesson 25.47 · PostgreSQL Operations · 25 min read

WAL, archiving and point-in-time recovery

In plain words

A careful accountant writes every transaction in a journal before touching the books. If the office floods, the books can be rebuilt from last month's copy plus the journal - and you can choose to rebuild them only up to Tuesday 14:31, the minute before someone made a terrible mistake.

The WAL is PostgreSQL's journal, archiving keeps every page of it somewhere safe, a base backup is last month's copy of the books, and point-in-time recovery is rebuilding up to the minute you choose.

Why this lesson exists

"Someone ran a DELETE without a WHERE at 14:32. Can we get the data back as it was at 14:31?" With last night's pg_dump the honest answer is "as it was last night". With a base backup and archived WAL the answer is "yes, to the second". The WAL is also what crash recovery, replicas and every serious backup tool are built on - and a broken WAL archive is one of the classic ways to fill a disk. This lesson is the WAL, checkpoints, archiving, base backups and point-in-time recovery (PITR), done by hand on the lab server.

What you need to know already: the data directory and pg_wal/ (lesson 2), restart vs reload (lesson 2), pg_dump and RPO/RTO (the last lesson), shell cp and permissions.

The words you need first

The WAL on this server

$ sudo -u postgres psql -c "select pg_current_wal_lsn(), pg_walfile_name(pg_current_wal_lsn())"
 pg_current_wal_lsn |     pg_walfile_name
--------------------+--------------------------
 0/29646D8          | 000000010000000000000002
(1 row)
$ sudo ls -l /var/lib/postgresql/18/main/pg_wal | head -n 5
total 81928
-rw------- 1 postgres postgres 16777216 Sep 22 20:02 000000010000000000000002
-rw------- 1 postgres postgres 16777216 Sep 22 20:02 000000010000000000000003
-rw------- 1 postgres postgres 16777216 Sep 22 20:02 000000010000000000000004
-rw------- 1 postgres postgres 16777216 Sep 22 20:02 000000010000000000000005
$ sudo -u postgres psql -c "select name, setting, unit from pg_settings where name in ('wal_level', 'max_wal_size', 'min_wal_size', 'checkpoint_timeout', 'archive_mode', 'archive_command', 'full_page_writes')"
        name        | setting | unit
--------------------+---------+------
 archive_command    |         |
 archive_mode       | off     |
 checkpoint_timeout | 300     | s
 full_page_writes   | on      |
 max_wal_size       | 1024    | MB
 min_wal_size       | 80      | MB
 wal_level          | replica |
(7 rows)

wal_level = replica (the default) writes enough for archiving and physical replicas; logical adds what logical decoding needs. A checkpoint happens every checkpoint_timeout (5 min) or when max_wal_size (1 GB) of WAL has been written since the last one, whichever comes first. If you see LOG: checkpoints are occurring too frequently (N seconds apart) with HINT: Consider increasing the configuration parameter "max_wal_size", the write load wants a bigger max_wal_size - frequent checkpoints mean much more I/O (every page changed after a checkpoint is written in full to the WAL once - full_page_writes).

Watch WAL being generated by a write:

$ before=$(sudo -u postgres psql -Atc "select pg_current_wal_lsn()"); echo $before
0/29646D8
$ sudo -u postgres psql -d orders -qc "update orders set updated_at = now() where id <= 5000"
$ sudo -u postgres psql -Atc "select pg_current_wal_lsn(), pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), '$before'))"
0/2B61AC8|2037 kB

The position moved by the WAL that 5,000 row updates wrote. pg_wal_lsn_diff(a, b) gives the bytes between two positions - the same arithmetic as replication lag in the next lesson.

Archiving

Three settings, two of them postmaster context:

$ sudo -u postgres mkdir -p /var/lib/postgresql/wal-archive
$ printf "archive_mode = on\narchive_command = 'test ! -f /var/lib/postgresql/wal-archive/%%f && cp %%p /var/lib/postgresql/wal-archive/%%f'\n" | sudo tee /etc/postgresql/18/main/conf.d/archive.conf
archive_mode = on
archive_command = 'test ! -f /var/lib/postgresql/wal-archive/%f && cp %p /var/lib/postgresql/wal-archive/%f'
$ sudo systemctl restart postgresql@18-main
$ ps -u postgres -o cmd | grep archiver
postgres: 18/main: archiver last was

%p is the segment's path, %f its file name. The command must:

In production the archive is not a local directory - it is object storage or a backup server, usually through a tool: pgbackrest archive-push %p, wal-g wal-push %p, barman-wal-archive. (18 can also use an archive_library module instead of a shell command.)

$ sudo -u postgres psql -Atc "select pg_switch_wal()"
0/2B61AC8
$ sudo ls /var/lib/postgresql/wal-archive/
000000010000000000000002
$ sudo -u postgres psql -c "select archived_count, last_archived_wal, failed_count, last_failed_wal from pg_stat_archiver"
 archived_count |    last_archived_wal     | failed_count | last_failed_wal
----------------+--------------------------+--------------+-----------------
              1 | 000000010000000000000002 |            0 |
(1 row)

pg_switch_wal() closes the current segment early (otherwise a quiet server archives only when 16 MB are full - archive_timeout forces it every N seconds). Watch failed_count: when the command fails, PostgreSQL retries forever and keeps every unarchived segment in pg_wal/ - the disk fills, and the log repeats archive command failed with exit code 1 with the command's own error. A failing archive is a disk-full incident in the making.

A base backup

$ sudo -u postgres pg_basebackup -D /var/lib/postgresql/backups/base -Fp -X stream -c fast -P
69652/69652 kB (100%), 1/1 tablespace
$ sudo ls /var/lib/postgresql/backups/base
PG_VERSION       base          pg_dynshmem   pg_notify    pg_snapshots  pg_subtrans  pg_wal
backup_label     global        pg_logical    pg_replslot  pg_stat       pg_tblspc    pg_xact
backup_manifest  pg_commit_ts  pg_multixact  pg_serial    pg_stat_tmp   pg_twophase  postgresql.auto.conf
$ sudo cat /var/lib/postgresql/backups/base/backup_label
START WAL LOCATION: 0/3000028 (file 000000010000000000000003)
CHECKPOINT LOCATION: 0/3000080
BACKUP METHOD: streamed
BACKUP FROM: primary
START TIME: 2026-09-22 20:10:22 UTC
LABEL: pg_basebackup base backup
START TIMELINE: 1

-X stream copies the WAL needed to make the copy consistent alongside it; -c fast asks for an immediate checkpoint instead of waiting for the next one; -Ft -z would write compressed tar files instead of a directory. backup_label records where the backup started - the server reads it when it starts from this directory. Since 17 there are also incremental base backups (summarize_wal = on, pg_basebackup --incremental=<manifest>, pg_combinebackup), and pg_verifybackup checks a backup against its backup_manifest.

Point-in-time recovery

The accident - at a moment we note:

$ sudo -u postgres psql -d orders -Atc "select count(*) from customers"
4000
$ t=$(date -u '+%Y-%m-%d %H:%M:%S+00'); echo "the last good moment: $t"
the last good moment: 2026-09-22 20:11:24+00
$ sudo -u postgres psql -d orders -c "delete from customers where country = 'NL' and id not in (select customer_id from orders)"
DELETE 0
$ sudo -u postgres psql -Atc "select pg_switch_wal()"
0/3407CF8

To get the rows back we restore the base backup as a second cluster next to production (never on top of it), replay the archive to just before the DELETE, and copy the rows over:

$ sudo -u postgres cp -a /var/lib/postgresql/backups/base /var/lib/postgresql/18/pitr
$ sudo pg_createcluster 18 pitr -d /var/lib/postgresql/18/pitr -p 5433
Configuring already existing cluster (configuration: /etc/postgresql/18/pitr, data: /var/lib/postgresql/18/pitr, owner: 114:114)
Ver Cluster Port Status Owner    Data directory              Log file
18  pitr    5433 down   postgres /var/lib/postgresql/18/pitr /var/log/postgresql/postgresql-18-pitr.log
$ printf "restore_command = 'cp /var/lib/postgresql/wal-archive/%%f %%p'\nrecovery_target_time = '$t'\nrecovery_target_action = 'pause'\narchive_mode = off\n" | sudo tee /etc/postgresql/18/pitr/conf.d/recovery.conf
restore_command = 'cp /var/lib/postgresql/wal-archive/%f %p'
recovery_target_time = '2026-09-22 20:11:24+00'
recovery_target_action = 'pause'
archive_mode = off
$ sudo -u postgres touch /var/lib/postgresql/18/pitr/recovery.signal
$ sudo pg_ctlcluster 18 pitr start
$ sudo tail -n 8 /var/log/postgresql/postgresql-18-pitr.log
2026-09-22 20:11:59.050 UTC [17519] LOG:  starting backup recovery with redo LSN 0/3000028, checkpoint LSN 0/3000080, on timeline ID 1
2026-09-22 20:11:59.050 UTC [17519] LOG:  redo starts at 0/3000128
2026-09-22 20:12:55.050 UTC [17520] LOG:  restored log file "000000010000000000000003" from archive
2026-09-22 20:12:55.050 UTC [17520] LOG:  consistent recovery state reached at 0/3000128
2026-09-22 20:12:55.050 UTC [17519] LOG:  database system is ready to accept read-only connections
2026-09-22 20:12:55.050 UTC [17520] LOG:  recovery stopping before commit of transaction 783, time 2026-09-22 20:11:54.550+00
2026-09-22 20:12:55.050 UTC [17520] LOG:  pausing at the end of recovery
2026-09-22 20:12:55.050 UTC [17520] HINT:  Execute pg_wal_replay_resume() to promote.

Read the recovery log: starting point-in-time recovery to the time we saved, restored log file "..." from archive for each segment, recovery stopping before commit of transaction N, time ... - the first transaction after the target was the DELETE - and pausing at the end of recovery. With recovery_target_action = 'pause' the server stays read-only, so you can look before you commit to that point:

$ sudo -u postgres psql -p 5433 -d orders -Atc "select count(*) from customers"
4000
$ sudo -u postgres psql -p 5433 -Atc "select pg_is_in_recovery()"
t

The rows are there. Copy them back into production (only the missing ones), then throw the recovery cluster away:

$ sudo -u postgres psql -p 5433 -d orders -Xc "\copy (select * from customers where country = 'NL') to '/var/lib/postgresql/nl-customers.csv' with (format csv)"
COPY 666
$ sudo -u postgres psql -d orders -c "create temp table nl (like customers)" -c "\copy nl from '/var/lib/postgresql/nl-customers.csv' with (format csv)" -c "insert into customers overriding system value select * from nl where id not in (select id from customers)"
CREATE TABLE
COPY 666
INSERT 0 0
$ sudo -u postgres psql -d orders -Atc "select count(*) from customers"
4000
$ sudo pg_dropcluster 18 pitr --stop

Had you wanted the recovered cluster to become the database (a full rollback of everything after 14:31), you would select pg_wal_replay_resume() (or set recovery_target_action = 'promote'): recovery ends, the server starts timeline 2, writes 00000002.history and accepts writes. Targets other than time: recovery_target_xid, recovery_target_lsn, recovery_target_name (set earlier with select pg_create_restore_point('before-migration')), and recovery_target_inclusive = off to stop just before the target instead of just after.

In practice a backup tool does the restore (pgbackrest restore --type=time --target='2026-09-22 14:31:00+00') and managed services offer "restore to a point in time" as a button that creates a new server. The mechanics underneath are exactly these.

In an interview: "Someone deleted rows an hour ago - how do you recover them?" - PITR: restore the last base backup into a separate cluster, set restore_command (archived WAL) and recovery_target_time just before the delete, recovery.signal, start it, check the log says it stopped before that transaction, verify the rows, copy them back to production. That needs continuous WAL archiving and base backups set up before the accident - a nightly pg_dump only gets you last night.

What you can do now

Why it helps

A nightly dump can only give you last night's data. With a base backup and continuous WAL archiving you can restore to any second - the answer to "someone deleted rows at 14:32". The same WAL is behind crash recovery and replicas, and a broken archive is one of the classic ways to fill a disk.

This lesson does PITR by hand, into a second cluster, which is exactly what backup tools and managed "restore to a point in time" buttons do underneath.

Commands in this lesson

psql ls mkdir printf systemctl ps pg_basebackup cat cp pg_createcluster touch pg_ctlcluster

FAQ

Why does a failing archive_command fill the disk?

PostgreSQL may only recycle a WAL segment after it has been archived. When the command fails, it retries forever and keeps every unarchived segment in pg_wal, so the directory grows with every write until the disk is full. Watch failed_count and last_archived_time in pg_stat_archiver, not only free space.

Why test ! -f in the archive command?

So the command refuses to overwrite a segment that already exists in the archive. If two servers were ever pointed at the same archive, they would otherwise silently overwrite each other's WAL and make the archive useless for recovery. The command must also return 0 only when the file is really stored.

Why restore into a separate cluster for PITR?

Recovering production itself to 14:31 throws away everything that happened after it, not just the mistake. A separate cluster lets you check the data at that moment and copy back only what was lost, while production keeps the rest of the day's changes. Only when you really want a full rollback do you promote the recovered copy.

What does recovery_target_action do?

It says what the server does when it reaches the target: pause (the default) leaves it read-only so you can look before deciding, promote ends recovery and makes it a writable primary on a new timeline, shutdown stops it. With pause, pg_wal_replay_resume() continues and ends recovery.

What is a timeline?

A numbered history of the WAL. When recovery ends (after PITR or promoting a replica), the server starts the next timeline and writes a .history file, so the WAL after the stop point on the old timeline can never be mixed with the new history. WAL file names start with the timeline number.

In an interview Mid

Someone deleted rows an hour ago. How do you recover them?

With PITR: restore the most recent base backup (pg_basebackup) into a separate cluster, set restore_command to fetch the archived WAL and recovery_target_time to just before the delete (recovery_target_inclusive = off), create recovery.signal and start it. The log should say "recovery stopping before commit of transaction ...". Check the rows are there, copy only the lost rows back into production, and drop the recovery cluster. This needs continuous WAL archiving and base backups set up before the accident; a nightly pg_dump would only give you last night's data.

Also asked: What is the WAL and what is a checkpoint? · How do you know WAL archiving is working? · What is the difference between promoting a recovered server and pausing it?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.