Backup & DRXIV · Database Backup and Point-in-Time RecoveryDatabases
Performing a point-in-time recovery
What you'll learn
- Sequence a point-in-time recovery from base backup and archive through to a running server
- Read a recovery log as the primary evidence of where replay stopped and why it stopped there
- Set recovery_target_time and recovery_target_action deliberately instead of inheriting the boot values
- Explain what the new timeline changes for the archive and for every later recovery from it
Prerequisites
Verified against restic 0.19.1 · BorgBackup 1.4.5 · rclone 1.75.0 · MinIO (S3-compatible object storage) RELEASE.2025-09-07T16-13-09Z · OpenZFS 2.4.1 · LVM2 2.03.31(2) · btrfs-progs 6.17.1 · PostgreSQL 18.6 · pgBackRest 2.59.1 · Kubernetes (k3s) and etcd k3s v1.36.3+k3s1, etcd 3.7.1 · Velero 1.18.2 · Docker Engine 29.7.2 · Proxmox Backup Server (documentation only) 4.0.10-1 · Ubuntu (host baseline) 26.04 LTS · 2026-08-28
Archive continuity was the previous lesson’s subject: a base backup plus an unbroken run of WAL segments after it is what turns a set of nightly instants into a range of recoverable moments. This lesson spends that archive. What follows is one capture on PostgreSQL 18.6 — a base backup, five thousand rows of ordinary business afterwards, a timestamp written down, an unqualified DELETE that emptied the table, and a recovery that put the rows back.
The procedure itself is four decisions and a start command. The part worth learning slowly is how the server narrates what it is doing, because the log states where replay stopped, and the data can only agree with it afterwards.
timeline
title Point-in-time recovery chooses a branch, not merely a file
Base backup : 45,000 rows : replay begins on timeline 1
Business WAL : 5,000 later rows : expected count 50,000 recorded
Recovery target : timestamp recorded : stop before transaction 836 commits
Destructive WAL : unqualified DELETE : excluded from replay
Promotion : validation returns 50,000 rows : timeline 2 begins
The target sits after the valid business writes and before the destructive transaction. Promotion creates a new history from that point; it does not erase timeline 1, and later recovery tooling must know which branch it is following.
What the archive was asked to undo
The setup exists to make the recovery checkable. A base backup was taken with
pg_basebackup -D /work/base -X stream -c fast, and it reported that it
contained 45000 rows. Business then continued: five thousand more orders
arrived, taking the table to 50000 rows and the business checksum to
sum(amount)=825025000, and a target time was recorded. Then the mistake, in
the form it usually takes — a DELETE with no WHERE clause.
$ pg_basebackup -D /work/base -X stream -c fast >>> exit code: 0
rows contained in the base backup: 45000
--- business continues after the backup: 5,000 more orders arrive ---
rows now : 50000
checksum of the business data : sum(amount)=825025000
recovery target time : 2026-08-28 13:34:40.077562+00
--- and then somebody runs an unqualified DELETE ---
rows after the mistake : 0
pg_stat_archiver:
archived=6 failed=0 last=000000010000000000000005
WAL segments in the archive : 5
000000010000000000000001
000000010000000000000002
000000010000000000000003
000000010000000000000003.00000028.backup
000000010000000000000004
000000010000000000000005Three things were written down before the incident and they are what make this recovery something other than a guess: a row count, a checksum over the money column, and a timestamp. None of them is a database feature. All three are decisions somebody made in advance about what evidence a recovery would have to produce, which is the same discipline this course applied to a restic repository in Part VIII and to an etcd snapshot in Part XIII.
The archive side is equally worth reading before starting anything.
pg_stat_archiver reports archived=6 failed=0 last=000000010000000000000005
and the listing shows six objects: five segments named
000000010000000000000001 through 000000010000000000000005, plus
000000010000000000000003.00000028.backup, the label file recording where the
base backup began. Six archived objects, six files present, nothing failed. The
recovery needs segments 3, 4 and 5 — everything from the base backup’s starting
segment forward. Segments 1 and 2 belong to history before the backup and will
not be touched.
Four decisions, then a start command
A point-in-time recovery is not a command. It is a directory plus three settings plus a file whose existence is itself a signal, and then an ordinary server start.
The first decision is where the base backup goes. Into a new directory,
always: /work/base in the capture, listening on port 5434, which left the
emptied cluster’s own data directory and port alone. Recovering beside the
damaged system rather than over it keeps the evidence, keeps the fallback, and
lets you compare the two.
The second is how the server obtains archived segments. restore_command
is a shell command with %f for the file the server wants and %p for where
it wants it put. The capture used cp, which is honest for a local archive
directory and would be an object-storage client or a pgbackrest invocation
elsewhere.
The third is where to stop: recovery_target_time, set to the timestamp
recorded before the DELETE. The fourth is what to do on arrival:
recovery_target_action, which the capture set explicitly to promote — as
the promotion at the end of its log shows, since the boot value would have
paused there instead.
Then the signal file. recovery.signal in the data directory is what tells the
server this is an archive recovery rather than a normal start; without it the
three settings above are simply not consulted.
BASE=/work/base
ARCHIVE=/work/wal-archive
TARGET='2026-08-28 13:34:40.077562+00'
cat >> "$BASE/postgresql.conf" <<EOF
restore_command = 'cp $ARCHIVE/%f %p'
recovery_target_time = '$TARGET'
recovery_target_action = 'promote'
EOF
touch "$BASE/recovery.signal"
pg_ctl -D "$BASE" start
restored log file, then recovery stopping before commit
With the settings in place the recovery is a server start, and from that moment the log is the instrument. Read it first, and read the data second.
$ pg_ctl -D /work/base start 2026-08-28 13:35:12.698 UTC [631] LOG: database system was interrupted; last known up at 2026-08-28 13:34:37 UTC
cp: cannot stat '/work/wal-archive/00000002.history': No such file or directory
2026-08-28 13:35:12.701 UTC [631] LOG: starting backup recovery with redo LSN 0/3000028, checkpoint LSN 0/3000080, on timeline ID 1
2026-08-28 13:35:12.707 UTC [631] LOG: restored log file "000000010000000000000003" from archive
2026-08-28 13:35:12.708 UTC [631] LOG: starting point-in-time recovery to 2026-08-28 13:34:40.077562+00
2026-08-28 13:35:12.708 UTC [631] LOG: redo starts at 0/3000028
2026-08-28 13:35:12.713 UTC [631] LOG: restored log file "000000010000000000000004" from archive
2026-08-28 13:35:12.713 UTC [631] LOG: completed backup recovery with redo LSN 0/3000028 and end LSN 0/3000120
2026-08-28 13:35:12.713 UTC [631] LOG: consistent recovery state reached at 0/3000120
2026-08-28 13:35:12.713 UTC [625] LOG: database system is ready to accept read-only connectionsFive of those lines carry the whole story of the replay, and one of them is not an error even though it looks like one.
database system was interrupted; last known up at 2026-08-28 13:34:37 UTC is
the copy admitting it is not a clean shutdown, which is expected: a base backup
of a live cluster never is. The cp: cannot stat line on stderr is the server
asking the archive whether a timeline history file exists, and being told no;
that question and its answer matter later.
starting point-in-time recovery to 2026-08-28 13:34:40.077562+00 is the line
to look for first, every time. It is the server reading back the target you
gave it. If that line is absent, no target is in effect, and there are two ways
to arrive there. Either recovery.signal was never created — the documented
way to start the server in targeted recovery mode at all, so without it
restore_command is not consulted and nothing is fetched from the archive — or
the signal file is present with no recovery_target_* setting alongside it, in
which case the documented behaviour is that recovery “ends when the archived
WAL is fully replayed”, the DELETE included, and the server reports success
while doing it. The two failures look nothing alike in the data and identical
in the exit status.
restored log file "000000010000000000000003" from archive, then the same line
for 000000010000000000000004 and 000000010000000000000005: each segment
arrives through restore_command, and each arrival is logged by its full name.
Three requested, three restored. These lines are the archive’s continuity being
demonstrated rather than assumed. Counting them against the segments you expect
is the cheapest check available during a recovery.
consistent recovery state reached at 0/3000120 is the one most people
misread. It is not the destination; it is the point at which the file copy
stops being torn. starting backup recovery with redo LSN 0/3000028 and
completed backup recovery with ... end LSN 0/3000120 bracket the WAL written
while pg_basebackup was running, and only once that range has been replayed
do the pages on disk describe a single coherent instant. That is why read-only
connections open at exactly that LSN and not before.
$ pg_ctl -D /work/base start 2026-08-28 13:35:12.726 UTC [631] LOG: restored log file "000000010000000000000005" from archive
2026-08-28 13:35:12.735 UTC [631] LOG: recovery stopping before commit of transaction 836, time 2026-08-28 13:34:42.096745+00
2026-08-28 13:35:12.735 UTC [631] LOG: redo done at 0/52EBC90 system usage: CPU: user: 0.01 s, system: 0.00 s, elapsed: 0.02 s
2026-08-28 13:35:12.735 UTC [631] LOG: last completed transaction was at log time 2026-08-28 13:34:38.041366+00
cp: cannot stat '/work/wal-archive/00000002.history': No such file or directory
2026-08-28 13:35:12.736 UTC [631] LOG: selected new timeline ID: 2
cp: cannot stat '/work/wal-archive/00000001.history': No such file or directory
2026-08-28 13:35:12.740 UTC [631] LOG: archive recovery complete
2026-08-28 13:35:12.740 UTC [629] LOG: checkpoint starting: end-of-recovery immediate wait
2026-08-28 13:35:12.744 UTC [629] LOG: checkpoint complete: wrote 1290 buffers (7.9%), wrote 3 SLRU buffers; 0 WAL file(s) added, 0 removed, 2 recycled; write=0.004 s, sync=0.001 s, total=0.004 s; sync files=6, longest=0.001 s, average=0.000 s; distance=35759 kB, estimate=35759 kB; lsn=0/52EBC90, redo lsn=0/52EBC90
2026-08-28 13:35:12.745 UTC [625] LOG: database system is ready to accept connectionsrecovery stopping before commit of transaction 836, time 2026-08-28 13:34:42.096745+00 is the sentence the whole recovery exists to produce. The
server read a commit record whose timestamp lies past the target and refused to
apply it. Note what the line does not say: it does not say what transaction 836
did. Given the sequence it is almost certainly the DELETE, but the server has
no opinion on that and neither should the log-reading. What is established is
narrower and stronger — nothing that committed at or after 13:34:42.096745 is
present in this database.
redo done at 0/52EBC90 fixes the stopping point in the WAL stream, and
last completed transaction was at log time 2026-08-28 13:34:38.041366+00
gives the other half of the picture. The target was 13:34:40.077562. The last
transaction actually applied committed at 13:34:38.041366, two seconds earlier.
Recovery did not stop at the target instant; it stopped at the last transaction
boundary before it, because those are the only points that exist.
selected new timeline ID: 2
The closing lines of the recovery are about the future rather than the past.
After reporting the last completed transaction the server asks the archive for
00000002.history, is told there is no such file, selects timeline 2, asks for
00000001.history as well, and declares archive recovery complete.
A timeline is a branch of WAL history. The database that has just been
recovered did not continue timeline 1 — it forked from it, at LSN 0/52EBC90,
and everything it writes from now on belongs to timeline 2. That fork is
visible in the segment names themselves: the leading field of
000000010000000000000005 is the timeline, which is why every segment this
archive holds begins 00000001 and the promoted server’s will begin
00000002. Two histories of the same database can therefore sit in one archive
without a single name collision.
That naming scheme is also why the log carries three .history probes rather
than none, because a shared archive has to be asked which branches it already
holds before a new one is taken. The first, before
replay begins, asks whether timeline 2 has already been claimed in this
archive; the answer was no, so 2 was free to take. The two after redo done
belong to writing the fork down. A later recovery reads those same files to
discover which timelines are already spoken for, which is why the promoted
server has to archive its history file and the archive has to keep it.
50000 rows and sum(amount)=825025000
Only now does the data get looked at, and only against numbers recorded before
the incident. The check is a count(*) and a sum(amount) over the business
table on the promoted instance, compared against the row count and the checksum
written down earlier — two comparisons, both against values the recovered
database could not have supplied.
$ psql -p 5434 rows recovered : 50000 (expected 50000)
sum(amount) : 825025000 (expected 825025000)
RECOVERED - row count and business checksum both match the pre-DELETE stateThe capture’s own note on this is the sentence to take away: what was validated
is “not that the server started, but that the data it now holds matches an
independently recorded property of the business data from before the incident”.
The server started in Part A of that same capture too, on a copy taken with
cp from a live data directory, and returned rows there as well. Starting is
cheap. Agreeing with a number nobody could have derived from the recovered
database is not.
Notice also that the two independent lines of evidence agree. The log said replay stopped before a commit at 13:34:42.096745 and after one at 13:34:38.041366; the table holds exactly the 50000 rows that existed in that window and the checksum that went with them. When those two disagree — a log that stopped where you asked and data that does not match — you have learned something important about your recorded expectations, and you have learned it before telling anyone the recovery is finished.
What none of this proves is that the target was the right target. A point-in-time recovery does exactly what it is told, to sub-second precision, and the precision is not the hard part. Establishing when the damage began usually is, and it comes from the application log, the audit trail or the pipeline that ran the statement, not from the database that faithfully recorded it.
What to take from this
- The base backup contained 45000 rows and the recovered database returned
50000 with
sum(amount)=825025000, matching the values recorded before the DELETE. The extra 5000 rows came out of the archive, not the backup. starting point-in-time recovery to 2026-08-28 13:34:40.077562+00is the line that proves the target was read at all. Without it no target is in effect: eitherrecovery.signalis missing and the archive is never consulted, or it is present with norecovery_target_*beside it and replay runs to the end of the archive. Both report success.recovery stopping before commit of transaction 836, time 2026-08-28 13:34:42.096745+00is the primary evidence of where recovery stopped, andlast completed transaction was at log time 2026-08-28 13:34:38.041366+00shows the effective point was 2.036196 seconds earlier than the target, because replay stops on commit boundaries.consistent recovery state reached at 0/3000120marks the end of the base backup’s WAL range, not the target; it is where the copy stops being torn and where read-only connections opened.recovery_target_actiondefaults topause, so a recovery that reaches its target holds there, read-only, until it is resumed or promoted. This recovery promoted at the target instead of holding, which is what setting the value topromoteproduces and the reason the log ends withdatabase system is ready to accept connections.selected new timeline ID: 2at LSN 0/52EBC90 means the recovered instance forked rather than continued. Its segments are named00000002..., both histories now live in the archive, and retention has to be decided for each.
Cross-course references
- PostgreSQL for Production Sysadmins — Part XIII (Backup, Archiving and
Point-in-Time Recovery) is the database administrator’s treatment of this
same operation, including the pgBackRest and pg_basebackup options this
lesson passes over; the
recovery_target_actionboot value carried forward here is the fact that course’s audit found stated wrongly in six places, so read the two together and trust the log over any summary of it. - Linux for Production Sysadmins — Part XXIV (Time Synchronisation) is a
direct dependency of everything above, because
recovery_target_timeis matched against timestamps written into commit records by the server’s clock: a host whose time drifted during the incident produces a recovery that stops somewhere other than where the operator believes, with no error anywhere. - Observability for Production Sysadmins — Part XXXI (Logging Foundations) covers the shipping and retention that decide whether the recovery log quoted in this lesson still exists when someone asks how the target was chosen; a recovery whose evidence lives only in a directory that was later discarded cannot be reviewed, only re-asserted.
Quiz
Knowledge check · 5 questions
Q1. A recovery is configured with recovery_target_time and recovery.signal, and recovery_target_action is left unset. The log reports that the target was reached, but the deployment script waiting for a writable database times out. What happened?
Q2. The target was 2026-08-28 13:34:40.077562+00. The log reported "recovery stopping before commit of transaction 836, time 2026-08-28 13:34:42.096745+00" and "last completed transaction was at log time 2026-08-28 13:34:38.041366+00". What does the recovered database contain?
Q3. The recovery log ended with "archive recovery complete" and "database system is ready to accept connections", so the recovery can be reported as successful.
Q4. Which of these are true of the recovery performed in this lesson? Select all that apply.
Q5. A colleague sends you the last line of a recovery log — "database system is ready to accept connections" — and asks whether the bad transaction was excluded. State which log lines you would ask for instead, and why they answer the question.
Passing score: 75%. Answers are checked in this browser.