recovery_min_apply_delay makes a standby late on purpose. For the length of the delay there is a running, queryable copy of your database on which the DELETE without a WHERE clause has not happened yet. That is the whole feature, and it is a good one. The problem is that almost everyone who sets it believes the next step is “promote the delayed standby,” and the next step is very specifically not that.
The default is 0, meaning no delay. The context is sighup, the unit is milliseconds, and the ceiling is 2147483647 ms, a little under 25 days. It arrived in 9.4 as a recovery.conf setting (the original commit called it min_recovery_apply_delay; it was renamed before release), which meant that through PostgreSQL 11 changing it required a restart. In 12 it moved into postgresql.conf with the rest of the recovery parameters and became reloadable, with one wrinkle: until the November 2021 minor releases (13.5, 12.9, and their siblings), a reload did not reach a wait already in progress, so setting the delay to 0 in an emergency did nothing until the current commit had served its original sentence. Every supported version has the fix. Set it to 0 and reload and the standby catches up at once; raise it mid-wait and the wait stretches to match.
What is late, and what is not
The startup process replays every record as fast as the walreceiver hands it over, with one exception. When it reaches a commit record (a plain COMMIT or a COMMIT PREPARED; aborts and PREPARE TRANSACTION records go straight through), it takes the commit timestamp the primary wrote into the record, adds recovery_min_apply_delay, and sleeps until the standby’s own clock gets there. You can watch it: the startup process shows up in pg_stat_activity with wait_event_type = 'Timeout' and wait_event = 'RecoveryApplyDelay'. Everything behind that commit in the stream waits behind it, because replay is sequential, so the standby’s visible state is precisely one delay old at every commit boundary. In my runs, now() - pg_last_xact_replay_timestamp() sat at 30.000 seconds against a 30-second delay, to the millisecond, for as long as the primary kept committing.
Clocks matter, and so does disk. The delay is measured between the primary’s clock and the standby’s, so a standby that is a minute fast is a minute less delayed; run NTP on both and a one-hour delay will not notice ordinary drift. And the data of a transaction is physically in the standby’s heap long before its commit replays. The rows are there; MVCC just will not show them to you. The standby takes the full WAL stream at the primary’s write rate, flushes it to pg_wal, and recycles none of it until a restartpoint, which cannot happen until a checkpoint record gets past the commits queued ahead of it. Sixty seconds of pgbench against a standby with a two-minute delay put 321 MB in the standby’s pg_wal against 193 MB on the primary, with 293 MB of it waiting to be applied. Size that volume for the delay times your peak WAL rate, plus a checkpoint cycle, plus a margin, because the day you need this standby is the day the primary is doing something unusual.
The delay does not start until the standby reaches a consistent state, so a freshly built or restarted standby replays history at full speed and only begins waiting when it reaches commits younger than the delay. (Before 9.4.7 it honored the delay from the first commit record it met, consistent or not, so a restarted delayed standby could refuse connections for up to the full delay while it crawled toward a consistent state.) It also does nothing during crash recovery. A primary with this parameter in its postgresql.conf ignores it entirely, which is harmless right up until that configuration file follows a pg_basebackup onto a new standby.
Promotion is not the undo button
Promotion ends the wait. The startup process checks for a promotion request on every pass through the delay loop, and once one arrives it stops waiting, applies every record it has already received, and switches timelines. On 19 RC1 with a 30-second delay: insert a row, delete the whole table, promote.
1 -- primary
2 insert into t values (6, 'row before the mistake');
3 delete from t; -- DELETE 7, committed at 05:18:41.10
4
5 -- standby, a few hundred milliseconds later
6 select count(*) from t; -- 5; two commits still in the window
7 select wait_event from pg_stat_activity
8 where backend_type = 'startup'; -- RecoveryApplyDelay
9
10 select pg_promote(); -- called 05:18:41.17, returns 05:18:41.46
11 select pg_is_in_recovery(), count(*) from t; -- f | 0
Under three hundred milliseconds from pg_promote() to a new timeline with no rows in it. The delayed standby is not a time machine. It is a time machine whose brakes come off the moment you turn the key. I have taken the call where a delayed standby was promoted to get a table back and arrived on its new timeline with the table already gone; it is a quiet call. [PLACEHOLDER: credential aside; confirm, replace, or cut.]
The same goes for anything that promotes on your behalf. A delayed standby cannot be a failover candidate, both because promotion replays the mistake and because it first has to replay a delay’s worth of WAL at recovery speed before it accepts a write. If Patroni is in the picture, the member gets nofailover: true, noloadbalance: true and nosync: true; whatever else you run, find the equivalent.
Pause first, then decide
The step that preserves the copy is pg_wal_replay_pause(), and it is the only step that has to happen inside the delay window. Call it on the standby the moment you learn about the mistake, before the meeting about the mistake.
1 -- primary, 05:09:28: the mistake
2 delete from t;
3
4 -- standby, 05:09:28
5 select pg_wal_replay_pause(), pg_get_wal_replay_pause_state();
6 | pause requested
7
8 -- the startup process is mid-wait on an earlier commit, so for the next
9 -- 26 seconds the state reads 'pause requested' and the wait event stays
10 -- RecoveryApplyDelay. Nothing is applied in the meantime.
11 -- 05:09:59:
12 select pg_get_wal_replay_pause_state(); -- paused
13 select count(*) from t; -- 3 rows, as of 05:07:40
Two things to know about that transcript. pg_wal_replay_pause() returns immediately, and the state reads pause requested until the wait already in progress runs out, which can take the full delay; that is cosmetic, since nothing is applied in the meantime, and the commit it was waiting on is held back too. And what you have once it reads paused is the database as of the last commit that had already cleared the delay (05:07:40 in the transcript), which is at least one delay old and older if the primary was quiet, not the database as of the moment before the mistake; the row inserted at 05:09:24 is not there. If a copy from an hour ago is good enough, and it often is, read what you need straight out of the paused standby with pg_dump -t, COPY ... TO STDOUT, or dblink from the primary. A paused hot standby generates no recovery conflicts, so the query can take as long as it likes without max_standby_streaming_delay cancelling it. Then pg_wal_replay_resume(), and the standby catches up.
If you need the database as of 05:09:27.9 exactly, you need a recovery target, and the recovery target parameters are postmaster context. With the standby still paused: ALTER SYSTEM SET recovery_target_time (or recovery_target_lsn, if you have the LSN) to just before the mistake, recovery_target_action = 'promote', and, the part nobody remembers, recovery_min_apply_delay = 0. Then restart. The reason for the last one is in the replay loop: a time target is checked before the delay for the record that trips it (an inclusive LSN or XID target, which is the default, stops after that record is applied, delay included), but every commit between the restart point and the target still serves its own delay first. With a two-minute delay left in force, recovering a two-row table on 18.6 looked like this:
1 05:15:41.629 LOG: starting PostgreSQL 18.6 ...
2 05:15:41.638 LOG: consistent recovery state reached at 0/3033790
3 05:17:31.312 LOG: recovery stopping before commit of transaction 755,
4 time 2026-10-20 05:15:41.342017+00
5 05:17:31.314 LOG: selected new timeline ID: 2
A hundred and ten seconds in RecoveryApplyDelay to replay two rows. The same procedure on 19 RC1 with the delay zeroed went from starting to selected new timeline ID: 2 in eleven milliseconds. Either way you now have a new primary on a new timeline holding exactly the table you wanted; copy it back to the real primary and rebuild the standby, because it is not a standby any more.
What does not work is pausing and then promoting. Promotion ends the pause and replays everything received, same as before; I tested it, and the table was just as empty. The pause buys you time to think. It does not change what promotion does.
Everything else that notices
hot_standby_feedback has to be off on this standby. With it on, the xmin the standby reports is the oldest transaction it has not yet seen commit, which by construction is one delay old, so the primary cannot remove anything deleted or updated in the last delay. After 30 seconds of modest pgbench traffic against a 60-second delay, the slot’s xmin on the primary was 6,016 transactions old and VACUUM VERBOSE reported 6,008 dead tuples it could not remove; with feedback off, the same vacuum removed them all. The documentation’s advice is to “use both together with care.” The care is “off.” (With a slot, the held xmin lives in pg_replication_slots.xmin rather than pg_stat_replication.backend_xmin; see the errata at the top of primary_slot_name.)
Keep it out of synchronous_standby_names unless every synchronous_commit = remote_apply transaction on the primary is meant to take one full delay to return. Mine took 10.006 seconds against a 10-second delay. on and remote_write are satisfied by the walreceiver, which is not delayed, and were unaffected.
Your replication-lag alert will fire, and the fix is to change what it measures. On the standby, now() - pg_last_xact_replay_timestamp() tracks the delay under steady commit traffic (it also counts aborts, which are not delayed, so a rolled-back transaction briefly pulls it toward zero, and an idle primary lets it climb past the delay), and pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn()) is the queue. On the primary, write_lag and flush_lag in pg_stat_replication are the health signals, because they measure the walreceiver. replay_lag is not usable for this standby on a busy primary: the walsender keeps 8,192 lag samples, a standby that is deliberately an hour behind is permanently behind the oldest of them, and the reported value becomes a sawtooth that climbs from a few seconds to the true delay and falls back. At 20 transactions per second it read 30 seconds steadily; at 1,300 it cycled between 7 and 29. Before the November 2025 minor releases (18.1, 17.7, 16.11, 15.15, 14.20, 13.23) the lag tracker had a worse problem: once the replay position stopped advancing, as it does on a paused standby, which is exactly what this post tells you to do, write_lag and flush_lag froze too, and those are the two you need.
A standby cascading from the delayed standby is not delayed. The cascading walsender ships what its own walreceiver has flushed, not what the startup process has applied, so a commit on the primary reached my cascade within two seconds and the delayed standby in the middle thirty seconds after the commit. Convenient if you want the delayed copy in the middle of a chain; a trap if you clone it, because pg_basebackup copies postgresql.auto.conf, and the clone comes up with the delay (and, as covered under primary_conninfo, the slot name) of the server it was copied from.
RDS for PostgreSQL supports it on 14.19, 15.14, 16.10 and 17.6 and later minors of those majors (the page says nothing about 18 yet), capped at 24 hours, with the recovery_target_* parameters available (static, so a reboot) and pg_wal_replay_pause() granted to rds_superuser. Aurora does not have it.
How long? Longer than it takes someone to notice. The delay has to cover the gap between the mistake and a human typing pg_wal_replay_pause(), and in most shops that gap is “when the morning report looks wrong,” which argues for hours, not minutes. Eight hours is a reasonable starting point, provided pg_wal can hold eight hours of your busiest day and nobody is also counting on this standby for failover or read scaling, because it is useless for both. It is not a backup, either; it protects you from transactions, and only for a window. What it buys is a copy that is already running and one function call from being frozen in place, and that is worth a server. Set the delay, write the pause command on a card, and tape it to the monitor. The delay buys the time. The pause is what buys the database.