Skip to main content
RunBook Academy

PostgreSQLXIV · Replication, Slots and Read ReplicasReplication

Replica conflicts and hot_standby_feedback

Advanced⏱ ~35 min🧪 Lab requiredpsql

What you'll learn

  • Explain why a standby cancels queries at all
  • Choose between cancellation, staleness and primary bloat deliberately
  • Monitor the horizon a standby holds, in the right place
  • Diagnose a conflict from the error and from pg_stat_database_conflicts

Prerequisites

Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27

Not yet marked complete on this device.

A standby replays WAL and serves read queries from the same pages. When replay needs to change a page a running query is reading, something has to give.

Why the conflict exists

Lesson XIV-01 established that replay is the crash recovery process, applying records strictly in order. It cannot skip a record, cannot reorder them, and cannot apply half of one.

Meanwhile a query on the standby holds a snapshot, which requires that the row versions its snapshot can see continue to exist.

The primary knows nothing about that query. It vacuums on its own schedule, removes row versions that are dead by its own reckoning, and ships the WAL that says so. Replay must apply it.

Two irreconcilable requirements, and PostgreSQL resolves them by cancelling the query.

With feedback off

Read-only / Safethe setup
$ SELECT name, setting FROM pg_settings WHERE name IN ('max_standby_streaming_delay','hot_standby_feedback');
            name             | setting
-----------------------------+---------
hot_standby_feedback        | off
max_standby_streaming_delay | 1000

A query on the standby opens a snapshot and holds it. On the primary, rows that snapshot can see are deleted and vacuumed:

Read-only / Safethe primary removes what the standby still needs
$ DELETE FROM conflict_demo WHERE id % 2 = 0;
VACUUM (VERBOSE) conflict_demo;
DELETE 100000
tuples: 100000 removed, 100000 remain, 0 are dead but not yet removable
Service impact possiblewhat the standby query received
$ -- the session that had been holding the snapshot
ERROR:  canceling statement due to conflict with recovery
DETAIL:  User query might have needed to see row versions that must be removed.

max_standby_streaming_delay bounded the wait at one second, and the query lost. That setting is how long replay will wait before cancelling, not a query timeout — the clock is on replay, not on the query.

With feedback on

hot_standby_feedback = on makes the standby report its oldest xmin to the primary, which then treats it as it would a local transaction’s horizon.

Read-only / Safethe same test, feedback on
$ -- on the standby, after the sleep
   after_sleep
-------------
      200000
Read-only / Safewhat it cost the primary
$ DELETE FROM conflict_demo WHERE id % 3 = 0;
VACUUM (VERBOSE) conflict_demo;
DELETE 66667
tuples: 0 removed, 200000 remain, 66667 are dead but not yet removable
removable cutoff: 229577, which was 1 XIDs old when operation ended

Zero removed. A read query on a different machine held the primary’s vacuum horizon exactly as a long local transaction would — everything in lesson VII-05 applies, including the table that went from 1.7 MB to 19 MB and never came back.

Read-only / Safeand once the standby query ended
$ VACUUM (VERBOSE) conflict_demo;
tuples: 66667 removed, 133333 remain, 0 are dead but not yet removable

All 66,667 removed on the next pass. The bloat was deferred, not avoided — which is the honest way to describe what feedback buys you.

With a large delay instead

The third option is to keep feedback off and let replay wait.

Read-only / Safereplay held off by one conflicting query
$ -- sampled on the primary every 6 seconds
   t+6s   replay_lag=00:00:05.999552  behind=43 MB
 t+12s  replay_lag=00:00:12.052013  behind=74 MB
 t+18s  replay_lag=00:00:18.104707  behind=96 MB
 t+24s  replay_lag=00:00:24.159844  behind=112 MB
 t+30s  replay_lag=00:00:30.004856  behind=124 MB

-- after the standby query ended:
        replay_lag=00:00:00.001752  behind=0 bytes

The query survived. The standby served data 30 seconds stale, and would have gone on falling behind for as long as the query ran.

What to take from this

  • Replay cannot yield, and a snapshot cannot lose its row versions. Something must give.
  • max_standby_streaming_delay is how long replay waits, not a query timeout.
  • Feedback on: the query survived, and the primary’s vacuum removed 0 of 66,667 dead tuples until it ended. Bloat deferred, not avoided.
  • With a slot, the held horizon is on pg_replication_slots.xmin, not pg_stat_replication.backend_xmin. Measured NULL five times running.
  • Large delay instead: 124 MB and 30 seconds behind, from one query.
  • Feedback does not prevent lock conflicts. DDL on the primary still cancels standby queries.
  • Failover target and reporting replica want opposite settings. Use two.

Cross-course references

  • Observability for Production Sysadmins — Part LIX (Database observability) covers exporting conflict counters per standby, which is how a reporting replica’s cancellations become visible to the team running the primary.
  • Linux for Production Sysadmins — Part LXXXII (Root cause analysis) covers tracing a cancelled query on one host to a setting changed on another.

Quiz

Knowledge check · 6 questions

  1. Q1. A team enables hot_standby_feedback to stop query cancellations on their reporting replica. Cancellations drop but do not disappear, and the survivors correlate with schema migrations. Why?

  2. Q2. A primary shows steadily growing bloat. Autovacuum runs and reports tuples not yet removable. pg_stat_replication.backend_xmin is NULL for the one connected standby. What should be checked next?

  3. Q3. What does max_standby_streaming_delay actually measure?

  4. Q4. Which are genuine costs of hot_standby_feedback = on? Select all that apply.

  5. Q5. With hot_standby_feedback on, the dead tuples vacuum could not remove during a standby query are removed by the next vacuum after it ends.

  6. Q6. A single standby serves both as the failover target and as the reporting replica. Explain why this configuration disappoints, and what to do instead.

Passing score: 75%. Answers are checked in this browser.