Promoting a PostgreSQL Standby with Patroni Without Data Loss
Problem statement: you need to move the primary role to another node β planned or otherwise β and you need a guarantee, not a hope, that no committed transaction is lost in the process.
Patroni can give that guarantee, but only if four settings are consistent with each other. This page is about those four settings, the arithmetic that ties them together, and how to verify the guarantee held after the fact. The broader mechanics are covered in replica failover and promotion mechanics; this page is the Patroni-specific runbook.
Symptom Identification
You need this configuration if any of the following describes your cluster:
patronictl listshows standbys with a non-trivialLag in MBcolumn during normal operation, and no setting prevents one of them being promoted.- The Patroni cluster has
synchronous_mode: false(the default) but the team believes failover is zero-loss becausesynchronous_standby_namesis set inpostgresql.conf. - A previous failover was followed by user reports of βmissingβ recent records, and nobody can state how many transactions were lost or prove that any were.
maximum_lag_on_failoveris at its default and has never been discussed as a durability budget.- Patroni once refused to promote during an incident and the response was to force it, rather than to read why it refused.
The last one is worth dwelling on. A refusal to promote is Patroni declining to lose data without permission. Forcing it converts a controlled outage into an uncontrolled data-loss event, and it usually happens at 3 a.m. under pressure because nobody had decided the policy in advance.
Root Cause Analysis
Zero-loss promotion requires two independent guarantees, and each is easy to have without the other.
The first is that some standby has the data. That is what synchronous replication provides: the commit does not return to the client until a synchronous standby has the WAL record. Setting synchronous_standby_names in postgresql.conf achieves this.
The second is that the failover controller promotes that particular standby. Nothing in postgresql.conf provides this. If Patroni is free to promote whichever node it likes, it may promote an asynchronous one that never received the transaction β and the fact that a synchronous standby existed and had the data is irrelevant, because that node is now a stale sibling of the new primary.
Patroniβs synchronous_mode is precisely the bridge between the two. When it is on, Patroni manages synchronous_standby_names itself, records the current synchronous node in the consensus store, and refuses to promote any node that is not in that set. The two guarantees become one.
The second root cause is the TTL inequality. Even with the right candidate chosen, promotion is only safe if the old primary has certainly stopped accepting writes. Patroni relies on a lease: the primary holds a key in etcd with a ttl, renews it every loop_wait, and demotes itself if it cannot. For this to fence reliably, the primary must have given up before anyone else takes over:
ttl > loop_wait + 2 Γ retry_timeoutIf retry_timeout is large enough that a primary can spend longer than ttl failing to reach etcd while still believing itself primary, there is a window in which two nodes are writable. The inequality is not a tuning suggestion; it is the safety condition.
Step-by-Step Resolution
Step 1 β Verify the TTL inequality holds
patronictl -c /etc/patroni.yml show-config | grep -E 'ttl|loop_wait|retry_timeout'With the common defaults ttl: 30, loop_wait: 10, retry_timeout: 10, the inequality reads 30 > 10 + 20, which is equality, not inequality β the margin is zero. Raise ttl to 40 or lower retry_timeout to 7.
Inline verification: python3 -c "ttl,lw,rt=40,10,10; print('safe' if ttl > lw + 2*rt else 'UNSAFE')" prints safe.
Step 2 β Turn on synchronous mode and decide the strict setting
patronictl -c /etc/patroni.yml edit-config -s synchronous_mode=trueThen make the strict decision deliberately. synchronous_mode_strict: false keeps writes flowing when the last synchronous standby dies, at the cost of a window where zero-loss promotion is impossible. true blocks writes instead. Write the choice, and the reason for it, into the runbook β because whichever one bites you, the incident review will ask who chose it.
Inline verification: patronictl list gains a Sync Standby column; exactly one node should show Sync Standby while the others show Replica.
Step 3 β Set the durability floor
patronictl -c /etc/patroni.yml edit-config -s maximum_lag_on_failover=1048576One megabyte of WAL is a common starting point. Express it as a policy statement rather than a number: βwe will accept losing at most one megabyte of WAL β roughly a few thousand small transactions β to restore service automatically; beyond that a human decides.β
Inline verification: during a drill, pause replay on the intended candidate (SELECT pg_wal_replay_pause()), let it fall behind the threshold, kill the primary, and confirm Patroni refuses to promote that node and logs the reason.
Step 4 β Run a switchover, not a failover, whenever you can
# Planned move: the old primary is shut down cleanly before anything is promoted.
patronictl -c /etc/patroni.yml switchover \
--leader pg-node-1 --candidate pg-node-2 --scheduled nowswitchover requires a healthy leader and refuses if the candidate is too far behind. That refusal is the feature. failover is the unplanned path and should be reserved for when the leader is genuinely gone.
Inline verification: patronictl list shows pg-node-2 as Leader and pg-node-1 as Replica with a running state and near-zero lag.
Step 5 β Prove the promotion was lossless
-- On the NEW primary: where did its own WAL history start?
SELECT timeline_id, redo_lsn FROM pg_control_checkpoint();
-- The switchpoint recorded when it left recovery:
SELECT * FROM pg_read_file('pg_wal/' || lpad(to_hex(timeline_id), 8, '0') || '.history');For a clean switchover the old primaryβs final flush LSN equals the new primaryβs start LSN. Any gap is exactly the data that was lost, measured in bytes.
Inline verification: the .history fileβs switchpoint LSN matches the last value the old primary reported in pg_current_wal_flush_lsn() before shutdown.
Configuration Snippet
# /etc/patroni.yml β the settings that determine whether promotion loses data.
scope: pg-prod
namespace: /service/
bootstrap:
dcs:
# Safety condition: ttl > loop_wait + 2 Γ retry_timeout.
# 40 > 10 + 14 leaves a 16-second margin.
ttl: 40
loop_wait: 10
retry_timeout: 7
# Durability floor: refuse to auto-promote a candidate further behind
# than this many WAL bytes. This IS your RPO, expressed in bytes.
maximum_lag_on_failover: 1048576
# Bridge between "a standby has the data" and "that standby is promoted".
synchronous_mode: true
# false = availability over durability when the last sync standby dies.
# true = durability over availability (writes block). Choose deliberately.
synchronous_mode_strict: false
postgresql:
use_pg_rewind: true # rejoin diverged nodes instead of rebuilding
parameters:
wal_log_hints: on # REQUIRED for pg_rewind β needs a restart to enable
synchronous_commit: 'on' # do not set to 'off': it defeats synchronous_mode
postgresql:
callbacks:
# Fires on role change; use it to repoint the routing tier deterministically
# rather than waiting for a health check to notice.
on_role_change: /usr/local/bin/patroni-repoint.shTwo of these interact in a way that catches people. synchronous_commit: 'off' at the PostgreSQL level makes commits return before the local WAL flush, which silently voids the guarantee synchronous_mode is trying to provide β Patroni will happily manage a synchronous standby set whose acknowledgements no longer gate anything. And wal_log_hints cannot be turned on without a restart, so a deployment that discovers it is off during an incident is committed to rebuilding every diverged standby from a base backup.
Verification and Rollback
Confirming the configuration is live, not merely written to a file:
# The running cluster's view β this is what actually governs behaviour.
patronictl -c /etc/patroni.yml show-config
# Per-node state, including which node is currently synchronous.
patronictl -c /etc/patroni.yml list
# Expect exactly one Leader, exactly one Sync Standby, the rest Replica.
# The PostgreSQL side that Patroni manages for you β do not edit by hand.
psql -h leader -c "SHOW synchronous_standby_names;"Drill the guarantee rather than assuming it, on a deployment shaped like production:
# 1. Write a marker transaction and record its LSN.
psql -h leader -c "CREATE TABLE IF NOT EXISTS failover_probe(t timestamptz);"
psql -h leader -c "INSERT INTO failover_probe VALUES (now());
SELECT pg_current_wal_flush_lsn();"
# 2. Hard-kill the leader (SIGKILL, not a clean shutdown β that is the point).
ssh pg-node-1 'sudo kill -9 $(pgrep -f "postgres: .*writer")'
# 3. After Patroni promotes, the marker must be present on the new leader.
psql -h $(patronictl list -f json | jq -r '.[]|select(.Role=="Leader").Host') \
-c "SELECT count(*) FROM failover_probe;"If the marker is missing, the promotion was not lossless and the configuration does not do what you believed. That is far better learned in a drill than in an incident.
Rollback. To revert to the previous behaviour, patronictl edit-config -s synchronous_mode=false. Do this only as a deliberate, time-boxed measure β for example, when a synchronous standby is unavailable for an extended maintenance and blocking writes is unacceptable. Set a reminder to re-enable it, because a deployment that quietly stays asynchronous is one whose next failover loses data.
Edge Cases and Gotchas
A cascading standby can never be the synchronous node
A standby that replicates from another standby rather than from the primary is invisible to synchronous_standby_names, because the primary has no replication connection to it. In a cascading topology the synchronous set can only be drawn from the first level. If your only same-region node is a cascade leaf, you have no zero-loss candidate no matter what is configured β the fix is topological, not a setting.
maximum_lag_on_failover is measured against the leaderβs last known position
Patroni compares each candidate against the last position it observed from the leader, which is stored in the consensus store. If the leader died between updates, that reference is slightly stale and the comparison is optimistic by up to one loop_wait of WAL. On a very high write-rate cluster this is worth accounting for: set the threshold below your true tolerance by roughly one loop-waitβs worth of WAL.
Manual pg_promote() bypasses everything on this page
Running pg_promote() or pg_ctl promote directly on a node that Patroni manages promotes it without any of the checks described here β no lease verification, no candidate comparison, no synchronous constraint. Patroni will then discover a second leader and act to resolve it, usually by demoting and rewinding one of them. Never promote a Patroni-managed node by hand; use patronictl so the safety machinery runs.
FAQ
Does synchronous_mode alone guarantee zero data loss?
It guarantees that a synchronous standby has the transaction before the commit returns, which is the necessary half. The sufficient half is that the failover controller actually promotes that standby. Patroni enforces this by only allowing a node in the synchronous set to be promoted while synchronous_mode is on, so both halves hold together. Turning synchronous_mode off and relying on synchronous_standby_names alone breaks the second half.
What does synchronous_mode_strict actually change?
It decides what happens when no synchronous standby is available. With strict off, Patroni clears synchronous_standby_names so the primary keeps accepting writes unreplicated β availability is preserved and zero-loss promotion is not. With strict on, the primary blocks writes until a synchronous standby returns β durability is preserved and the service stalls. There is no correct default; it is a business decision that must be made explicitly.
Why did Patroni refuse to promote any node?
Almost always because every candidate exceeded maximum_lag_on_failover, or because synchronous_mode is on and no node in the synchronous set is reachable. Both are the configuration working as designed: it is refusing to trade durability for availability without being told to. patronictl list shows each nodeβs lag, and the Patroni log states the specific reason for each rejected candidate.
Is switchover safer than failover?
Substantially. A switchover shuts the old primary down cleanly first, which means it has flushed everything and cannot accept another write, so the fencing problem disappears and data loss is structurally impossible. Failover happens when the old primary is already gone and cannot be asked to cooperate. Whenever you have the option β maintenance, instance replacement, a node showing early failure signs β convert the failover into a switchover.
Related
β Back to Replica Failover & Promotion Mechanics
- Preventing Split-Brain During Automated Replica Promotion β the fencing side of the same lease arithmetic.
- repmgr Standby Promotion and Rejoin Runbook β the equivalent sequence without a consensus store.
- Understanding Synchronous vs Asynchronous Replication β what the commit path costs when zero loss is required.