Every parameter in this post answers one of three questions about a restore from a WAL archive: where should replay stop, which history should it follow to get there, and what should the server do when it arrives. Eight parameters, three questions. recovery_target, recovery_target_lsn, recovery_target_name, recovery_target_time and recovery_target_xid are five ways of answering the first. recovery_target_timeline answers the second. recovery_target_inclusive adjusts the first by one WAL record, and recovery_target_action answers the third.
The defaults answer all three questions: replay to the end of the archive, along the newest timeline in it, and come up as a primary when it gets there. The first of those is the right answer when the primary has burned down, and the wrong one when somebody dropped a table, because the end of the archive contains the DROP TABLE. The point of this post is the eight lines you write to get a different answer, and the handful of ways those eight lines fail.
All eight are postmaster context. They are read at server start and do nothing unless a recovery.signal or standby.signal file is present; a primary with recovery_target_time set in its configuration runs normally (not harmlessly, it turns out, but normally). Until PostgreSQL 11 they lived in recovery.conf; 12 moved them into postgresql.conf with everything else, which is also when they became visible in pg_settings and settable with ALTER SYSTEM. The five stopping-point parameters are mutually exclusive. Since 12, setting two of them is a fatal error at startup; before that the last one in the file silently won.
Five ways to say where
recovery_target = 'immediate' stops replay the moment the server reaches a consistent state, which for a backup taken with pg_basebackup or pg_backup_start()/pg_backup_stop() is the end of the backup. It is a string parameter whose only legal value is immediate, a design that is exactly as odd as it sounds. Use it when you want the backup itself and nothing that happened afterwards: cloning a copy for a developer, checking that last night’s backup restores at all, or getting a table back without caring how fresh it is. It is also the fastest restore there is, since it replays only the WAL generated while the backup was running.
recovery_target_name stops at a named restore point, a WAL record you wrote earlier with pg_create_restore_point('before_the_migration'). The record is a no-op during replay; it exists only to be found. If several restore points share a name, recovery stops at the first one it meets. This is the target to use when you know in advance that something risky is about to happen, and it is the only one of the five whose value you chose instead of looked up.
recovery_target_time stops at a timestamp. Replay compares it against the commit (and abort) records in the WAL, and nothing else; a time target never looks at the clock on any other record. The value is parsed as a timestamp with time zone, with two restrictions: the words now, today, tomorrow and yesterday are rejected at startup, and so are time zone abbreviations like CEST unless timezone_abbreviations was set earlier in the same file. Write a numeric offset. Always. The server’s timezone setting decides how a value without an offset is read, and the server doing the restore does not necessarily have the primary’s timezone:
1 recovery_target_time = '2026-10-20 10:49:17' # postgresql.conf says America/Los_Angeles
2 LOG: starting point-in-time recovery to 2026-10-20 10:49:17-07
3 ...
4 FATAL: recovery ended before configured recovery target was reached
The starting point-in-time recovery to line prints the target as the server understood it. Read it before you walk away.
recovery_target_xid stops at the commit or abort record of one transaction, matched by equality: replay does not stop at “the first transaction numbered above 756”, because transaction IDs are handed out at start and transactions finish in whatever order they please. (The source has a comment on this, noting that a higher-numbered transaction completes first about half the time.) Equality has consequences. The ID must be the top-level transaction’s, because a subtransaction’s ID appears inside its parent’s commit record and recovery looking for it never stops. A transaction that never committed or aborted in the archived WAL never stops recovery either. And an ID is only unique within a timeline, which I will come back to. The docs have said since 8.0 that this target is not very usable for want of tools to find the ID; pg_waldump -r Transaction on the archived segments is the tool, and it is better than it sounds, because the commit record of a DROP TABLE lists the relfilenodes it unlinked:
1 $ TZ=UTC pg_waldump -r Transaction 000000010000000000000003
2 rmgr: Transaction len (rec/tot): 1849/ 1849, tx: 756, lsn: 0/0300FB08, prev 0/0300FAD0, desc: COMMIT 2026-10-20 10:49:17.809203 UTC; rels: base/5/16385 base/5/16384 base/5/16391 ...
That one line gives you the XID, the exact commit timestamp, and the LSN. Three of the five targets, from one grep. (pg_waldump prints timestamps in its own process’s time zone, hence the TZ=UTC; see the previous paragraph.)
recovery_target_lsn (added in 10) stops at a WAL position, and unlike time and XID it considers every record, not just transaction ends. The comparison is against the start of each record: recovery stops at the first record whose start position is at or past the target. If you take the LSN from pg_current_wal_insert_lsn() while a transaction you want to exclude is still open, the commit record you are trying to exclude begins at exactly that LSN, and the default inclusive setting replays it. This is where recovery_target_inclusive comes in.
recovery_target_inclusive
Boolean, default on, and it applies only to the time, XID and LSN targets. It decides what happens to the record that satisfies the target. For an XID, on replays that transaction’s commit record and stops after it; off stops in front of it, so the transaction’s changes exist on disk but never commit. For an LSN, on replays the record starting at or after the target; off stops in front of it. For a time, the rule is about ties: on stops before the first commit record later than the target, so transactions that committed at exactly the target microsecond are included; off stops before the first commit at or later than the target. With microsecond commit timestamps, a tie happens when you took the timestamp from a commit record, which is to say when you used pg_waldump and did it right. Here is the drop above, targeted at its own commit timestamp, both ways:
1 recovery_target_time = '2026-10-20 10:49:17.809203+00'
2 LOG: recovery stopping before commit of transaction 757, time 2026-10-20 10:49:18.865464+00
3
4 recovery_target_time = '2026-10-20 10:49:17.809203+00'
5 recovery_target_inclusive = off
6 LOG: recovery stopping before commit of transaction 756, time 2026-10-20 10:49:17.809203+00
The first restore has no table t once promoted; the second has it, with every row that was committed before the drop. “Once promoted” is deliberate. Stopping in front of a commit has a side effect the docs do not mention: on a hot standby, replay takes the same AccessExclusiveLock the original DROP TABLE took, and releases it when the commit record is replayed. Stop before the commit record and the startup process holds that lock for as long as the server stays paused. A SELECT from the table you just rescued waits on it forever; neither of the standby delays helps, because replay is not trying to make progress and so has nothing to cancel:
1 ERROR: canceling statement due to lock timeout
2 CONTEXT: waiting for AccessShareLock on relation 16385 of database 5
(That is with lock_timeout set, on 19, which adds the CONTEXT line; without a timeout the query just sits there.) The table is there; you cannot look at it until you promote.
recovery_target_action
An enum, default pause, with promote and shutdown as the alternatives. It does nothing unless a stopping point is set. It arrived in 9.5 to replace the boolean pause_at_recovery_target, whose idea of “pause” when hot_standby was off had been to promote without telling anyone.
pause stops replay at the target and leaves the server open for read-only connections, so you can check that this is the point you wanted. It requires hot_standby; with it off, pause is silently treated as shutdown. The pause is a one-way door. pg_wal_replay_resume() does not continue replay past the target; it ends recovery and promotes (the log says so: Execute pg_wal_replay_resume() to promote), and since 13 so do pg_ctl promote and pg_promote(), which on 12 and earlier were ignored while paused. If the point you reached is too early, the only way forward is to stop the server, set a later target and start it again; replay restarts from the last restartpoint (or the backup’s checkpoint, if there has not been one) and runs forward to the new target. The practical hazard is that pg_ctl start -w returns success as soon as the paused server accepts connections, so a restore script that does not know about the pause proceeds to its next step and gets cannot execute INSERT in a read-only transaction, while the restore sits at its target waiting for a human who thinks it is still replaying.
promote is what you want when nobody is going to look: recovery stops at the target, picks a new timeline, writes the history file, removes recovery.signal and opens for writes. (What else happens in that moment is the recovery_end_command post.)
shutdown is the one to be careful with. The server stops cleanly at the target and pg_controldata reports shut down in recovery, on the original timeline, with recovery.signal still in place. The docs say the server will have to replay WAL from the last checkpoint when next started, and that any subsequent start will shut down again at the target unless you change the configuration or remove recovery.signal. Do not remove recovery.signal. Without it the next start is crash recovery, and crash recovery replays everything in pg_wal, where restore_command left every segment it fetched, including the part of the last one that lies past your target:
1 $ rm recovery.signal && pg_ctl start
2 LOG: database system was not properly shut down; automatic recovery in progress
3 LOG: redo done at 0/3025FD0
4 ...
5 pg_is_in_recovery | tables
6 -------------------+------------
7 f | after_drop
The table that was dropped is dropped again, on timeline 1, with no history file to tell you anything happened. The correct way to bring a shutdown restore up is to leave the signal file and the target alone, change recovery_target_action to promote, and start it; it replays to the same stopping point and promotes there. The legitimate use of shutdown is a cold copy of the data directory at an exact point, taken by a tool that will also restore it with the same target.
recovery_target_timeline
A string: latest, current, or a timeline number (prefix with 0x to use the hexadecimal from a WAL file name). The default has been latest since 12; before that it was the timeline the backup was taken on, which 12 named current so you can still ask for it. latest is the right default for a standby, which must follow its primary through a promotion, and for almost every restore. The docs compare point-in-time recovery to time-travel fiction, and this parameter is where the comparison earns its keep: every archive recovery that completes starts a new timeline, and once you have restored to a point before the drop, promoted, and let the application write, the archive holds two histories that share a prefix and disagree after it.
The disagreement is total, and XIDs do not help you tell the histories apart. The new timeline resumes transaction IDs from where the old one was at the branch point, so the same number means different transactions on each:
1 recovery_target_xid = '755' # default timeline 'latest'
2 LOG: recovery stopping after commit of transaction 755, time 2026-10-20 10:57:32.612517+00
3 t rows: 1:in base backup | 2:after backup | 35:written on timeline 2
4
5 recovery_target_xid = '755'
6 recovery_target_timeline = 'current'
7 LOG: recovery stopping after commit of transaction 755, time 2026-10-20 10:49:16.755335+00
8 t rows: 1:in base backup | 2:after backup | 3:last good row
Same base backup, same archive, same target. A target that exists only on the old timeline past the branch point, like the DROP TABLE itself, is simply never found when recovery follows latest. That is the situation this parameter exists for: you restored, you promoted, and now you want something from the history you abandoned. Set current (or the number) and the restore follows the old timeline all the way to its end, ignoring the branch. You cannot recover into a timeline that branched off before the base backup was taken, which is one more reason to take a fresh base backup as soon as a point-in-time restore goes into production.
When the target is not there
Since 13, a target that recovery never reaches is a fatal error at the end of the WAL:
1 LOG: redo done at 0/402BB48
2 LOG: last completed transaction was at log time 2026-10-20 10:54:48.163106+00
3 FATAL: recovery ended before configured recovery target was reached
4 LOG: shutting down due to startup process failure
The server replays the entire archive first, so the message arrives after the wait, not before it, and the data directory is left in archive recovery with the target still set. On 12 and earlier the same situation promoted silently at the end of WAL, which is how people discovered that their time zone was wrong a day later. A subtransaction’s XID, an XID that never finished, a time written in the wrong zone, and a target that lives on a timeline you are not following all end here. The opposite mistake has its own message: a target that falls before the backup finished, with a transaction that committed during the backup standing between them, fails with requested recovery stop point is before consistent recovery point, because stopping there would leave a database that was never consistent. If nothing committed during the backup, the same target quietly stops just before the first commit after the backup, which is the backup’s own state and therefore fine.
The more dangerous version of “not there” is a target nobody meant to set. These parameters live in postgresql.auto.conf as happily as anywhere else, and ALTER SYSTEM accepts them on a running primary; a reload logs only that they cannot be changed without a restart. pg_basebackup copies postgresql.auto.conf into every standby it builds. So a server that was restored with ALTER SYSTEM SET recovery_target_name = 'before_drop', promoted, and put back into production hands that line to every standby cloned from it. A standby honours recovery targets. With a time target, it stops at the first commit record later than the target, which on a freshly restored server is the first commit it replays, and pauses; at least that is loud. With a name or XID target that does not exist on the new timeline, the standby streams for months without complaint, and then:
1 $ pg_ctl promote
2 LOG: received promote request
3 LOG: redo done at 0/6000028
4 FATAL: recovery ended before configured recovery target was reached
5 LOG: shutting down due to startup process failure
The failover fails, and the standby is down. After any point-in-time restore, ALTER SYSTEM RESET every one of these parameters before the server is backed up or cloned.
One last way to set a target you did not mean. The mutual-exclusion check runs when each parameter is assigned at startup, in file order, and setting a stopping-point parameter to the empty string is how you un-set the one before it. So recovery_target_name = 'before_drop' followed by recovery_target_xid = '' is a fatal multiple recovery targets specified, while the same two lines in the other order work. A configuration template that writes all five keys, with blanks for the unused ones, works only when the one real value happens to be the last of the five in the file. The source comment above the check hooks describes the arrangement as “broken by design”, which is at least honest. Write only the one parameter you are using, and nothing for the other four.
(A note for 19: it documents that recovery_target_xid accepts the 64-bit value pg_current_xact_id() returns and uses only the low 32 bits, which 18 already did without saying so, and its check hook finally rejects values below 3 and trailing garbage; 18 reads '756garbage' as 756 without a word.)
What goes in the file
For a point-in-time restore with a person watching: one of recovery_target_time with an explicit UTC offset or recovery_target_name, nothing else from the five, recovery_target_inclusive left at on, recovery_target_timeline left at latest unless this is a re-recovery, and recovery_target_action left at pause. Check the starting point-in-time recovery to line, check the data, run pg_wal_replay_resume(). For a scripted restore, the same with recovery_target_action = 'promote', because a script cannot be trusted to notice a pause. For “give me last night’s backup”, recovery_target = 'immediate' and promote. For a cold copy at an exact point, shutdown, and bring it up with promote, never by deleting the signal file.
Use recovery_target_xid when a commit record from pg_waldump is what you have, and remember which timeline it came from. Never use the time target without an offset, never write a blank value for a target you are not using, and reset all eight once the restore is in production. The restore itself is the easy part; these are the eight lines that decide what it restores.