Reported symptoms
A migration script issued an unqualified DELETE at 14:07 and removed
5,000 rows from ledger.
The team began a point-in-time recovery onto a spare host, targeting 14:06. The recovery completed, the server became writable, and the rows were still missing.
A second attempt targeting 14:05 was started against the same restored directory and failed immediately. The base backup had to be restored again — fifty minutes — before the second attempt could begin.
A third attempt, targeting a time before the base backup, failed with
recovery ended before configured recovery target was reached.
Total elapsed time from the DELETE to correct data: four hours and
twenty minutes.
Evidence provided
The first recovery configuration set recovery_target_time and did not
set recovery_target_action. That omission is a red herring, and the log
below is what proves it: there is no recovery stopping before line, so
the target was never reached and no action was ever taken.
$ grep -E 'timeline|archive recovery|redo done' postgresql.log2026-08-28 06:04:46.302 UTC [3143] LOG: redo done at 0/3A19A2B8 system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 23.05 s
2026-08-28 06:04:46.329 UTC [3143] LOG: selected new timeline ID: 2
2026-08-28 06:04:46.352 UTC [3143] LOG: archive recovery complete
2026-08-28 06:04:46.358 UTC [3137] LOG: database system is ready to accept connectionsThe 14:06 target came from the migration job’s log, which records local time. PostgreSQL records UTC.
$ pg_ctl -D /restore start && tail -3 postgresql.log2026-08-28 06:05:08.073 UTC [3255] LOG: redo done at 0/3A1A7E58 system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.02 s
2026-08-28 06:05:08.073 UTC [3255] FATAL: recovery ended before configured recovery target was reached
2026-08-28 06:05:08.074 UTC [3249] LOG: shutting down due to startup process failureAnd a recovery configured with recovery_target_action = 'pause':
$ grep -E 'starting point-in-time|consistent|recovery stopping|pausing' postgresql.log2026-08-28 06:04:23.247 UTC [3143] LOG: starting point-in-time recovery to 2026-08-28 06:04:03.779666+00
2026-08-28 06:04:23.269 UTC [3143] LOG: consistent recovery state reached at 0/39000120
2026-08-28 06:04:23.274 UTC [3143] LOG: recovery stopping before commit of transaction 780, time 2026-08-28 06:04:05.83324+00
2026-08-28 06:04:23.274 UTC [3143] LOG: pausing at the end of recovery in_recovery | pause_state | replayed_to
-------------+-------------+-------------
t | paused | 0/3A19A2B8
total | before_backup | after_backup | marker
-------+---------------+--------------+--------
15001 | 10000 | 5000 | 1
Work the evidence before reading on
- The first recovery “completed” and the rows were missing. What did it actually do?
- Why could the second attempt not reuse the restored directory?
recovery ended before configured recovery target was reached— what are the two ways to produce that?- What would
recovery_target_action = 'pause'have changed?
Root cause
A target that is never reached promotes, whatever the action says
The target was wrong, and that should have been cheap
14:06 came from a log recording local time; PostgreSQL records UTC. The
recovery replayed past the DELETE and reproduced it faithfully.
The recovery worked. The target did not.
Two ways to fail to reach a target
recovery ended before configured recovery target was reached means
PostgreSQL replayed everything it had and never got there. Either the
target is beyond the end of the archive, or it is before the base
backup — and no recovery can reach a moment earlier than the backup it
starts from.
PostgreSQL refuses to present that as a successful recovery to the requested point, which is correct.
Resolution
Always set recovery_target_action = 'pause'. It is the single most
valuable setting in a point-in-time recovery and it costs nothing:
restore_command = 'cp /archive/%f %p'
recovery_target_time = '2026-08-28 14:06:00+00'
recovery_target_action = 'pause'
Note the explicit UTC offset, and take the target from the database’s record of the event — the server log — not from an application log in local time.
Create recovery.signal, whose presence is what makes this a recovery
rather than a crash restart:
touch /var/lib/postgresql/18/restore/recovery.signal
Start the server and read the log:
grep -E 'starting point-in-time recovery|recovery stopping|pausing at the end|redo done|FATAL' \
/var/lib/postgresql/18/restore/log/postgresql.log
Then inspect, while you still have every option:
SELECT pg_is_in_recovery(), pg_get_wal_replay_pause_state(), pg_last_wal_replay_lsn();
SELECT count(*) FROM ledger;
If the data is right, promote:
SELECT pg_promote();
If it is not: change recovery_target_time to a later value and
restart, which continues replaying from where it is. To go earlier,
restore the base backup again — replay cannot run backwards.
If you see recovery ended before configured recovery target was reached, check both ends:
-- on the source cluster
SELECT last_archived_wal, last_archived_time FROM pg_stat_archiver;
ls /archive | tail -3
cat /var/lib/postgresql/18/restore/backup_label
Verification
The paused cluster contains the data you expected, checked before
promotion. On the measured recovery, the table a DROP had removed was
present again and the 5,000 deleted rows were back — 15,001 rows against
the 10,000 that existed at backup time.
The log contains recovery stopping before commit of transaction N, time ..., which names the exact transaction you stopped at. That is the
strongest available statement of where you landed.
After promotion, pg_is_in_recovery() is false, the log shows
selected new timeline ID and archive recovery complete, and a write
succeeds.
The history file records the target, which distinguishes a PITR from a plain failover months later:
cat pg_wal/00000002.history
A PITR names the target — before 2026-08-27 21:22:51.345091+00 — where
a promotion writes no recovery target specified.
The recovery was timed. Restoring the base backup took fifty minutes, and that number belongs in the disaster-recovery plan rather than being discovered during an incident.
Prevention
Set recovery_target_action = 'pause' in every recovery, without
exception. Free, reversible, and its absence cost fifty minutes per
wrong guess.
Take the target from the database’s own log, in UTC, with an explicit offset. An application log in local time is the most common way to miss a target.
Choose a target slightly before the event and walk forward. Undershooting is cheap; overshooting is not.
Never trust pg_ctl’s exit status for a recovery.
Rehearse a PITR on a schedule and time each phase. A team that has never done one will discover the pause setting, the time zone, and the fifty minutes during a real incident, in that order.
Tie base backup cadence to the recovery time objective. Every recovery starts from a base backup and replays forward.
Verify the archive is complete before you need it. A recovery is bounded by the base backup at one end and the last archived segment at the other, and a gap between them stops replay at the gap.
Write the runbook with the exact configuration file contents. A recovery is not the moment to reconstruct a configuration from memory.