Execute a PostgreSQL point-in-time recovery
1 · Prerequisites
Confirm every item is in place before any state change.
- A base backup taken before the moment you need to return to. In the measured capture it was produced by
pg_basebackup -D /work/base -X stream -c fastand contained 45000 rows. - A WAL archive that runs continuously from that base backup forward. The recovery reads the archive, not the damaged cluster, and that is what makes it independent of the machine that failed.
- A target time in UTC with an explicit offset, derived from the incident timeline. The database recorded the damage faithfully; it cannot tell you when the damage began.
- An invariant recorded before the incident that the recovered data must reproduce — a row count and a checksum over a business column. In the capture these were 50000 rows and
sum(amount)=825025000. - Somewhere to recover that is not the damaged cluster: a separate directory or host, the same PostgreSQL major version, and room for the whole data directory. In the capture the recovered instance listened on port 5434, which its own log records, and nothing was written over the source data directory.
- A
restore_commandthat has already been run by hand, as the postgres OS user, against a segment you know is in the archive. - A named person who authorises promotion, and written agreement that everything committed after the target will not exist in the recovered cluster.
2 · Pre-checks
Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.
- · Fix the recovery point from the incident timeline, not from the latest available data. "Restore the latest" is the instruction that reapplies the damage. The capture recorded
recovery target time : 2026-08-28 13:34:40.077562+00before the DELETE ran, and that timestamp came from outside the database. Write it in UTC with an explicit offset; a target without one depends on atimezonesetting nobody checked. - · Confirm the base backup predates the target. Read
backup_labelin the restored directory for the backup start time and the segment replay begins from. No setting makes a recovery reach a moment earlier than the backup it starts from. - · Confirm the archive is continuous from the base backup to the target BEFORE starting.
SELECT archived_count, failed_count, last_archived_wal FROM pg_stat_archiver;on the source, then list the archive and check the segment names form an unbroken run. A gap between the backup and the target means the target is unreachable, and you will discover that only after the restore has cost you its full duration. - · Understand what a gap does, because it does not look like a failure. Replay asks
restore_commandfor each segment by name; a non-zero exit is the documented way to say the file is not there, and that answer ends recovery rather than failing it. A gap therefore produces a shorter recovery that reports success. - · **Confirm
archive_modewill beoffon the recovered copy.** The copy carries the source cluster's configuration verbatim,archive_modeandarchive_commandincluded. Left on, the recovered instance writes its own timeline into the archive that production depends on. - · Confirm you are recovering beside the damaged cluster and never over it. Recovering over production destroys the evidence and the fallback in one step, and it removes the option of comparing the two.
- · Record the invariant you will check afterwards, and who supplied it. A row count and a checksum over a business column, agreed with the data owner before the recovery starts. An invariant chosen after seeing the recovered data proves nothing.
- · Budget the time. Restoring the base backup is the expensive stage and each wrong target costs it again. Note the start time so the elapsed cost of a second attempt is a measured number rather than an argument.
3 · Procedure
Execute each step in order. Verify the expected output of a step before moving to the next.
- 1**Restore the base backup into a new directory owned by the postgres user with mode
0700.** Never unpack it over the damaged data directory: while the original cluster survives, every wrong decision below is reversible. - 2Set the port to something production is not using. The capture's recovered instance listened on 5434, which its log records. A distinct port is what lets the copy and the original exist at the same time, and comparing the two is the only cheap way to tell what the recovery actually changed.
- 3**Set
archive_mode = offin the recovered copy's configuration, as a separate deliberate line.** This is the step a runbook forgets, and its failure mode is delayed: the copy writes00000002segments and a history file into the shared archive, and the next recovery from that archive discovers a branch production never took. - 4**Set
restore_commandto read the archive, and nothing else.**restore_command = 'cp /srv/rbdr-wal-archive/%f %p'for a local directory; an object-storage client or a pgBackRest invocation elsewhere. It must not be able to write. - 5**Set
recovery_target_timeto the target you fixed in the pre-checks**, quoted, in UTC, with the offset written out. - 6**Set
recovery_target_actionexplicitly, and default topause.** Its documented boot value ispause, so this is not defence against the boot value — it is defence against a template that carriespromote, and it makes the intent readable to whoever reviews the recovery later. - 7**Create
recovery.signalin the restored directory.** Without it the three recovery settings are not consulted at all, and what starts is an ordinary crash restart that replays everything it can find, damage included, and reports success. - 8Start the server and read the log, not the exit status of the start command. The start command succeeded in the capture's first part too, on a copy that proved nothing.
- 9Confirm the log echoes your target back.
starting point-in-time recovery to 2026-08-28 13:34:40.077562+00is the line that proves the settings were read. If it is absent, stop and fix the configuration before replay goes anywhere. - 10**Count the
restored log file ... from archivelines against the segments you expected.** These are the archive's continuity being demonstrated rather than assumed, and it is the cheapest check available while the recovery is running. - 11Find the stopping line and record it verbatim.
recovery stopping before commit of transaction 836, time 2026-08-28 13:34:42.096745+00is the strongest available statement of where replay landed, andlast completed transaction was at log time ...gives the other bracket. - 12**Note
selected new timeline ID: 2and what it commits you to.** The recovered instance forked rather than continued; its segments are named00000002..., the archive now holds two histories, and retention has to be decided for each one separately. - 13Check the business invariant while the decision is still open, then promote only when the data has been checked and the named authoriser has said so.
- 14Take a fresh base backup on the new timeline as soon as the recovered instance is accepted, and let that backup — not the promotion — be the event that permits any pruning of what came before it.
4 · Verification
Confirm the procedure actually fixed the problem.
- ✓The log contains
starting point-in-time recovery to <the timestamp you configured>, in UTC. Absence of this line means the target was never read. - ✓The log contains one
restored log file "..." from archiveline per segment between the base backup and the target, and their names form an unbroken sequence. - ✓The log contains
consistent recovery state reached at <LSN>, which marks the end of the base backup's own WAL range — the point at which the file copy stops being torn, not the target. - ✓The log contains
recovery stopping before commit of transaction N, time <timestamp>, and that timestamp is at or after the target. A recovery with no stopping line ran past the target to the end of the archive. - ✓The log contains
selected new timeline ID: Nandarchive recovery complete, and the number is one the archive did not already hold. - ✓The archive file count is identical before and after the recovery, proving
archive_mode = offheld and the copy wrote nothing into it. - ✓The recovered database reproduces the invariant recorded before the incident — row count and business checksum both — checked by query rather than by inspection.
- ✓The two independent lines of evidence agree: the log says replay stopped between two named commit timestamps, and the data holds exactly what existed in that window. Disagreement means the recorded expectation was wrong, and you have learned that before telling anyone the recovery is finished.
5 · Rollback
If verification fails, undo the procedure in reverse order.
- ↶Before promotion, everything is reversible, and that is the whole reason
recovery_target_actionis set topause. Nothing below costs more than time. - ↶To move the target later: edit
recovery_target_timeand restart the server. Replay continues forward from where it paused, which costs seconds rather than another base backup restore. - ↶To move the target earlier: restore the base backup again and start over. Replay cannot run backwards, and no setting makes it.
- ↶After promotion there is no rollback inside this cluster. The remedy is a second recovery from the base backup, at the full cost of the procedure.
- ↶If the recovered copy was started with
archive_modeleft on, stop it immediately, list what it added to the archive, and quarantine those files with the archive owner before any further recovery reads them. A stray00000002.historytells every later recovery that a branch exists which production never took. - ↶Undo the reflex mutations as well as the deliberate ones: alert silences opened during the incident, monitoring checks pointed at the recovery port, connection strings edited to test the copy, and any
restore_commandleft in a shared configuration file. - ↶Keep the damaged cluster and the base backup until the recovered instance has been verified and has served long enough to trust. Deleting either one converts a recoverable mistake into a permanent one.
6 · Escalation
When the runbook isn't enough, contact:
- ·
FATAL: recovery ended before configured recovery target was reached: escalate and check both ends. Either the target is past the end of the archive, or it is before the base backup. Neither is fixed by retrying. - · The archive has a gap between the base backup and the target: escalate to the archive owner. The recovery can only reach the gap, and that is a capability limit of the estate rather than a mistake in this procedure.
- · The data at the pause point is not what anyone expected and no better target can be derived: escalate to the data owner before promoting. Promotion converts an uncertain recovery into a permanent one.
- · The recovered copy has written into the shared archive: escalate to the archive owner and to whoever holds the next recovery, because the damage is to future recoveries rather than to this one.
- · The recovery is on the critical path of an outage and is exceeding the agreed recovery time objective: escalate to the incident owner with the measured remaining work. Serving partial or stale data is somebody else's decision.
- · The event being recovered from looks deliberate rather than accidental: escalate to security before recovering. The recovery discards the evidence of what else the actor did, and a copy has to be preserved first.
- · Nobody can authorise the promotion and the paused cluster is holding resources: escalate rather than promoting to tidy up. A paused recovery is a safe state and can be left in one.
A point-in-time recovery does exactly what it is told, to sub-second precision. The precision is not the hard part. Choosing the moment is, and proving afterwards that the moment was the right one is the rest of the work. This runbook is arranged so that the only irreversible step — promotion — happens last, after somebody has looked at the data.
Select the recovery point before touching the archive
Recovery point selection is a step, not a default. “Restore the latest” is the instruction that reapplies the damage, because the latest state of the archive includes the transaction you are recovering from. The target comes from the incident timeline: the audit trail, the pipeline that ran the statement, the application log. The database recorded the damage faithfully and has no opinion about when it started.
$ count the rows, checksum the money column, and record the recovery target time--- 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+00Three things were recorded before the incident and none of them is a database feature: a row count, a checksum over the money column, and a timestamp. They are decisions somebody made in advance about what evidence a recovery would have to produce.
Prove the archive reaches the target before you spend the restore
The base backup fixes the earliest reachable moment; the archive fixes the latest. Check both before restoring anything, because a restore that cannot reach its target costs its full duration to discover.
$ SELECT archived_count, failed_count, last_archived_wal FROM pg_stat_archiver, then list the archive directory pg_stat_archiver:
archived=6 failed=0 last=000000010000000000000005
WAL segments in the archive : 5
000000010000000000000001
000000010000000000000002
000000010000000000000003
000000010000000000000003.00000028.backup
000000010000000000000004
000000010000000000000005Read the counts against each other and against the names. failed=0 is the
number that gates the recovery; a non-zero value means the archive is behind
the database and the target may sit in a segment that only ever existed on the
failed machine. Then read the names as a sequence. A gap is not reported as an
error anywhere: replay asks restore_command for each segment by name, and a
non-zero exit is the documented way to say the file is absent. That answer
ends recovery rather than failing it, so a gap produces a shorter recovery
that reports success.
Recover a copy, with archive_mode = off
ARCHIVE=/srv/rbdr-wal-archive
BASE=/srv/rbdr-base
RESTORE=/srv/rbdr-pitr-restore
TARGET='2026-08-28 13:34:40.077562+00'
cp -a "$BASE" "$RESTORE"
ls "$ARCHIVE" | wc -l > /srv/rbdr-archive-count.before
cat >> "$RESTORE/postgresql.auto.conf" <<EOF
port = 5434
archive_mode = off
restore_command = 'cp $ARCHIVE/%f %p'
recovery_target_time = '$TARGET'
recovery_target_action = 'pause'
EOF
touch "$RESTORE/recovery.signal"
pg_ctl -D "$RESTORE" -l /srv/rbdr-pitr-restore.log start
recovery.signal is what makes this an archive recovery rather than a normal
start. Without it the three settings above are simply not consulted.
Read the log, then the data
$ start the recovered cluster, then read the target back out of its log 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/3000028Then the stop, which is the sentence the whole procedure exists to produce.
$ read where replay stopped and which timeline the recovery branched onto 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 completeThe stopping line names the exact transaction replay refused to apply. The
last completed transaction line gives the other bracket: the target was
13:34:40.077562 and the last commit applied was at 13:34:38.041366, because
replay stops on commit boundaries and those are the only points that exist.
The two cp: cannot stat lines are the server probing for history files and
being told there are none, which on a first recovery is normal.
selected new timeline ID: 2 is a commitment, not a cosmetic detail. The
recovered instance forked at LSN 0/52EBC90; its segments will be named
00000002..., the archive now describes two histories of the same database,
and every recovery point before the fork still lives on timeline 1. Retention
that keeps “the last N segments” can quietly remove the branch a second,
better-targeted recovery would need.
Decision point: pause and inspect, or promote directly
recovery_target_action decides this, and its documented boot value is
pause. Set it explicitly either way.
| Situation | Choose | Why |
|---|---|---|
| The target is derived from an incident timeline you are still reconstructing | pause | The cluster is read-only, every option stays open, and a wrong target costs seconds to correct |
| The invariant is agreed but not yet checked | pause | Inspect before the timeline branches; promotion cannot be undone |
| A rehearsed restore test with a target proven by an earlier run | promote | Nothing to inspect, and an automated harness waiting for a writable database will otherwise time out |
| An automated pipeline with nobody watching the log | pause, and fail the pipeline | A pipeline that promotes on a target it cannot verify makes the wrong outcome permanent |
Decision point: the first target landed wrong
Replay is cheap forward and expensive backward, so bias the first attempt
early. If the data at the pause point is missing work you needed, move
recovery_target_time later and restart the server: replay resumes from where
it paused. If the data still contains the damage, the target was too late and
you restore the base backup again. When the log has already named the offending
transaction, recovery_target_xid is the sharper second attempt — the
timestamp is the blunt instrument, and the log line naming transaction 836 is
conveniently the input to a better-aimed run.
Abort criteria
Stop, leave the paused cluster where it is, and escalate when any of these hold. Do not promote to tidy up.
- The log has no
starting point-in-time recovery to ...line. The settings were not read and nothing about this run can be trusted. - The
restored log filenames skip a segment, or replay ends without a stopping line. The archive did not reach the target. FATAL: recovery ended before configured recovery target was reachedappears. The target is outside the range the backup and archive can express.- The archive file count changed during the recovery. The copy is writing into the shared archive and every later recovery is now affected.
- The invariant does not reconcile and no better target can be derived from the incident timeline.
- The incident looks deliberate. Recovery discards evidence, so a copy is preserved and security decides before anything else happens.
Validate against the business invariant, not against the process
Two checks, in this order. The first proves the recovery cost the shared archive nothing, and it is the one nobody remembers to write down.
ARCHIVE=/srv/rbdr-wal-archive
BEFORE=$(cat /srv/rbdr-archive-count.before)
AFTER=$(ls "$ARCHIVE" | wc -l)
test "$BEFORE" -eq "$AFTER" \
&& echo "archive untouched: $AFTER files" \
|| echo "ABORT: archive grew from $BEFORE to $AFTER"
The second is the one the recovery exists for.
$ count rows and sum amounts on the recovered cluster, and compare with the invariant recorded beforehand 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 is the sentence to carry: 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”. In the first part of that same capture a server also started, and also returned rows, having established nothing at all. Starting is cheap. Agreeing with a number that could not have been derived from the recovered database is not.
What to record
The target and where it came from; the stopping line verbatim; the segments consumed; the new timeline number and LSN; the archive file count before and after; the invariant and who supplied it; the named authoriser of the promotion; and the elapsed time split into restore, replay and verification. The last of those is the only honest input to the next recovery time estimate anyone makes.