recovery_end_command is the only hook in postgresql.conf that fires exactly once, at the moment a server stops being a restore or a replica and becomes a primary. It is a shell command. The default is empty, and empty is also my recommendation; what earns the parameter a post is what the server is doing while your command runs, and what it does when the command fails. The documentation spends two sentences on the second and none on the first.

The context is sighup, so you can set it on a running standby with ALTER SYSTEM and a reload, and it will fire at promotion without a restart. (It has been reloadable since PostgreSQL 12, when it moved from recovery.conf into postgresql.conf; recovery.conf was read once, at startup.) The command runs through sh -c, as the server’s OS user, with the data directory as the working directory, and whatever it prints lands in the server log with no line prefix, the same way restore_command’s complaints from cp do.

It runs only at the end of archive recovery: a recovery.signal restore reaching its target or running out of WAL, or a standby.signal server being promoted, whether by pg_ctl promote, pg_promote(), or pg_wal_replay_resume() after a recovery_target_action = 'pause'. It does not run after crash recovery. I set it on a primary, sent the postmaster SIGKILL, and started it again: the WAL was replayed and the command was not run. It also does not run under recovery_target_action = 'shutdown', since that stops the server before recovery ends, with recovery.signal still in place.

Between recovery and production

By the time the command starts, the startup process has already chosen the new timeline, written the timeline history file (and marked it for archiving, if archiving is on), deleted the signal file, logged “archive recovery complete”, and written either the end-of-recovery checkpoint (a restore) or the end-of-recovery record (a promotion). Then it runs your command and waits. Here is a standby with recovery_end_command = 'sleep 15; ...', four seconds after pg_ctl promote -W:

1postgres=# select pg_is_in_recovery(), wait_event
2 from pg_stat_activity where backend_type = 'startup';
3 pg_is_in_recovery | wait_event
4-------------------+--------------------
5 t | RecoveryEndCommand
6
7postgres=# insert into t(note) values ('during-end-command');
8ERROR: cannot execute INSERT in a read-only transaction
9
10$ ls standby.signal
11ls: cannot access 'standby.signal': No such file or directory
12$ ls pg_wal
1300000001000000000000000E 00000001000000000000000F 00000002.history
1400000002000000000000000F summaries
15$ pg_controldata . | grep state
16Database cluster state: in archive recovery

The log shows the gap:

101:01:56.803 LOG: archive recovery complete
201:02:11.813 LOG: database system is ready to accept connections

For the length of the command the server is neither thing. On disk it is on timeline 2; pg_is_in_recovery() says true; writes are refused; pg_controldata says “in archive recovery” (after a restore it says “shut down”, because the end-of-recovery checkpoint is a shutdown checkpoint). pg_ctl promote waits for the control file to say “in production”, with a default timeout of 60 seconds, so a command that takes longer than that gets you server did not promote in time and an exit status of 1, after which the server promotes anyway. Anything that calls pg_ctl promote and believes the answer now holds a failed failover that is about to succeed behind its back. Your failover clock is running the whole time.

How it fails

A nonzero exit status is a WARNING in the log, and startup proceeds. That covers exit codes 1 through 125. A command killed by a signal, a command the shell could not run (exit 126 or 127), and any exit code above 125 (so a script that ends in exit 255) are all treated as death by signal, and that is FATAL:

1LOG: selected new timeline ID: 2
2LOG: archive recovery complete
3FATAL: recovery_end_command "this_command_does_not_exist %r": command not found
4LOG: shutting down due to startup process failure

A typo in the command therefore takes a restore down at the very end, after all the WAL has been replayed. What it leaves behind is the interesting part. The timeline switch has happened, the signal file is gone, and the control file already says the cluster was shut down cleanly. Start the server again without touching anything and you get:

1LOG: database system was shut down at 2026-10-19 01:02:46 BST
2LOG: database system is ready to accept connections

No recovery, no new timeline, and no recovery_end_command, then or ever, because the only thing that runs it is archive recovery and that is over. The server is up on timeline 2 with the broken command still in postgresql.conf, waiting for the next restore. (The housekeeping the command sits in front of, removing the old timeline’s leftover segments from pg_wal, is skipped as well.) PostgreSQL 16 added one more way to trip this: a placeholder other than %r or %%, such as a %p copied over from restore_command, is rejected when the command is expanded, not when the setting is loaded, so it also surfaces as a FATAL at the end of recovery. On 14 and 15 the text goes to the shell as written; I checked on 15.19.

%r after a restore

%r expands to the segment name of the control file’s latest checkpoint redo location, on that checkpoint’s timeline, the same value archive_cleanup_command sees at each restartpoint. On a promotion that is the last restartpoint, on the old timeline, which is what the documentation’s “last valid restart point” leads you to expect. On a restore it is the end-of-recovery checkpoint, which is on the new timeline. The restore in these tests produced 000000020000000000000006.

The documentation describes the parameter as a cleanup mechanism and sends you to archive_cleanup_command for %r, where the example is pg_archivecleanup. pg_archivecleanup ignores the timeline part of the name and deletes every segment whose number sorts before the one it is handed. Put those together in a recovery.signal restore to a point partway through the archive:

1$ ls archive
2000000010000000000000001 000000010000000000000002 000000010000000000000002.00000028.backup
3000000010000000000000003 000000010000000000000004 000000010000000000000005
4000000010000000000000006 000000010000000000000007 000000010000000000000008
5
6recovery_end_command = 'pg_archivecleanup /path/to/archive %r'
7
8$ ls archive
9000000010000000000000002.00000028.backup 000000010000000000000006
10000000010000000000000007 000000010000000000000008

The timeline-1 segments the base backup depends on are gone, and the archive is now good only for restores to points after the one you just chose. A second attempt from the same base backup gets as far as the end of the backup’s own WAL and dies:

1cp: cannot stat '/path/to/archive/000000010000000000000003': No such file or directory
2FATAL: recovery ended before configured recovery target was reached

The first thing you learn after a point-in-time recovery is frequently that you wanted a different point. The advice from the archive_cleanup_command post applies with more force here: the archive belongs to the backup tool, and a command that runs once, at the moment your old timeline becomes history, is the worst available place to prune it.

What it was for

The parameter arrived in 8.4 to solve a problem of its era. pg_standby was the warm-standby tool of the day: restore_command would call it, and it would poll the archive for the next segment until a trigger file appeared. In 8.3 it removed the trigger file itself, which meant a failover could happen with WAL still sitting in the archive. 8.4 made it replay everything first by default, which left nobody to remove the trigger file, and recovery_end_command was added for the job, with rm -f /tmp/pgsql.trigger.5442 as the documented value. The pg_standby page put it plainly: “Without this option a leftover trigger file can be hazardous.” pg_standby was removed in 14 and the last trigger file, promote_trigger_file, in 16, and the parameter that existed to clean up after pg_standby is still here, in the same family as backslash_quote and lo_compat_privileges: settings that survive because removing them would break someone.

The one serious user I can point to is Barman, and it uses the parameter the way it should be used. A barman restore to a recovery target without --get-wal copies the WAL the recovery will need into a barman_wal directory under the destination, then writes restore_command = 'cp .../barman_wal/%f %p' and recovery_end_command = 'rm -fr .../barman_wal': a temporary copy it made, removed when it is done with it, with nothing in the archive at risk. Even that went wrong once; the 3.13.1 release notes describe a combination of --no-get-wal and --standby-mode under which the generated parameters, this one included, pointed at pg_wal itself, and pg_wal was what got removed.

Leave it empty. If you do use it, make it something that cannot fail and finishes in seconds: a notification, or deleting a scratch directory you created for this restore. Exit 0, or 1 if you must, never above 125; %r and %% are the only percent sequences it accepts; and never aim it at an archive you intend to restore from again. Do not set it with ALTER SYSTEM on a primary, either. pg_basebackup -R appends to a postgresql.auto.conf copied from the source (the primary_conninfo post has the details), so every standby cloned from that primary inherits the command and runs it, as the server’s OS user, the moment it is promoted. That one turned up by accident while testing this post, which is the only way it ever turns up.