PostgreSQL Replication and High Availability
PostgreSQL’s replication story is one of the most mature in the open-source database world, but it has a way of humbling you. Streaming replication looks simple until you hit your first replication slot that quietly fills your disk overnight. Patroni looks like magic until your etcd cluster loses quorum at 2 AM and the leader lock expires. Logical replication looks like the perfect zero-downtime upgrade path until you discover that ALTER TABLE is not replicated and your schema migration broke the subscriber.
I have managed PostgreSQL HA clusters in production — some handling hundreds of thousands of transactions per second, some backing payment systems where even a few seconds of data loss would be unacceptable — and the consistent lesson is that replication is a system you have to understand at every layer before you trust it. This post covers that system end to end: how WAL-based replication works, how to set it up correctly, when to use synchronous versus asynchronous replication, how logical replication unlocks capabilities physical replication cannot, how Patroni automates failover without introducing split-brain, and how to monitor all of it before a problem finds you.
How PostgreSQL Replication Works: The WAL Foundation
Every change in PostgreSQL — an INSERT, an UPDATE, a DELETE, a checkpoint — is first written to the Write-Ahead Log (WAL) before it touches data pages. This is the atomicity and durability guarantee: if the process crashes before writing data files, the WAL allows recovery on restart. Replication is, at its core, the application of this same mechanism across a network. Standbys replay the same WAL records the primary already generated. They are not executing queries; they are applying a deterministic log of physical changes.
This distinction matters enormously. Because physical replication replays at the block level — byte-for-byte copies of data pages — a standby running physical replication must be binary-identical to the primary. Same architecture, same major version, same tablespace layout. It cannot replicate a subset of tables, it cannot feed a different PostgreSQL version, and it cannot apply data to a table with a different column order. These limitations are not bugs; they are the direct consequence of operating below the SQL layer.
Logical replication, introduced in PostgreSQL 10 and improved in every major release since, operates at the row-change level. It decodes WAL into a stream of INSERT, UPDATE, and DELETE operations for specific tables and sends those operations to a subscriber. The subscriber can be a different PostgreSQL version, a different schema layout, or even a different database entirely. You pay for this flexibility: DDL changes are not replicated, sequences are not replicated, and the overhead of logical decoding is higher than raw WAL shipping.
Physical vs Logical at a Glance
| Dimension | Physical (Streaming) | Logical |
|---|---|---|
| Granularity | Entire cluster (byte-level) | Selected tables (row-level) |
| Cross-version | Same major version only | Different major versions |
| DDL replication | Yes (implicit, byte-level) | No — must manage separately |
| Readable standby | Yes (hot_standby = on) |
Yes (subscriber is a full instance) |
| Primary key required | No | Required for UPDATE/DELETE |
| Initial sync method | pg_basebackup |
Logical copy at subscription time |
| Overhead | Low (raw WAL transfer) | Higher (decoding + filtering) |
| Use case | HA standby, read replicas | Selective sync, upgrades, migration |
Streaming Replication Setup
The most common deployment pattern is a primary with one or more hot standby servers receiving the WAL stream in real time. Here is what a two-node primary-standby setup looks like:
Primary (10.0.0.10) Standby (10.0.0.11)
+------------------------+ +------------------------+
| | | |
| Client writes | | Read-only queries |
| | | | | |
| PostgreSQL engine | WAL | PostgreSQL recovery |
| writes WAL to pg_wal +------------>| replays WAL records |
| | stream | |
| WAL sender process | | WAL receiver process |
| (one per standby) | | writes to pg_wal |
+------------------------+ +------------------------+
| |
pg_stat_replication standby.signal exists
shows connection state primary_conninfo set
Primary Configuration
On the primary, postgresql.conf needs these parameters:
|
|
The replication connection needs its own entry in pg_hba.conf. This is the authentication file, not a firewall — every replication connection is authenticated through it:
# pg_hba.conf on primary
# TYPE DATABASE USER ADDRESS METHOD
host replication replicator 10.0.0.0/24 scram-sha-256
The DATABASE column must be replication — this is a special keyword, not an actual database name. The user named replicator must exist and hold the REPLICATION privilege. Create it:
|
|
Do not grant this role superuser. It only needs REPLICATION and LOGIN. Store the password in your secrets manager; it will need to appear in the standby’s primary_conninfo.
Building the Standby with pg_basebackup
With the primary configured, build the standby. On the standby server, as the postgres OS user:
|
|
Each flag matters:
-h 10.0.0.10— connect to the primary-U replicator— authenticate as the replication user-D /var/lib/postgresql/17/main— destination data directory; must be empty or non-existent--checkpoint=fast— trigger an immediate checkpoint on the primary before starting the backup, rather than waiting for the next scheduled one; shortens the backup window-P— print progress; useful for large databases-Xs— stream WAL during the backup using a separate WAL sender process; prevents WAL rotation from outpacing the base backup on a busy primary-R— writestandby.signaland populatepostgresql.auto.confwithprimary_conninfoautomatically
The -R flag is the key quality-of-life addition. Without it you would need to write standby.signal manually and add primary_conninfo to postgresql.auto.conf by hand. With it, pg_basebackup writes:
|
|
The standby.signal file is an empty file in the data directory. Its presence tells PostgreSQL on startup to enter recovery mode and attempt to connect to the primary. When you promote the standby (via pg_ctl promote or a Patroni failover), standby.signal is removed and the instance becomes a standalone primary.
Start the standby with your normal init script. Check the logs: you should see LOG: started streaming WAL from primary at ... within a few seconds.
A note on pg_basebackup vs the old approach: before pg_basebackup was mature, DBAs used pg_start_backup() + rsync + pg_stop_backup(). This required manual WAL archiving and careful handling of the backup label. pg_start_backup() was removed in PostgreSQL 17. If you find documentation still referencing this approach, it is outdated. Use pg_basebackup.
Verifying the Connection: pg_stat_replication
On the primary, pg_stat_replication shows one row per connected standby. Here is annotated example output:
|
|
The progression sent_lsn >= write_lsn >= flush_lsn >= replay_lsn always holds. The gap between sent_lsn and replay_lsn in bytes is your replication lag. The replay_lag timestamp column converts this to wall-clock time, which is more intuitive for alerting — but it measures the lag at the moment the standby last sent a status message, not necessarily right now.
Replication Slots
By default, the primary will recycle WAL files based on checkpoint history and wal_keep_size. If a standby falls behind by more than wal_keep_size megabytes of WAL — because it was restarted, the network was unstable, or a long-running query held back replay — the primary will recycle the WAL the standby still needs, and replication will fail with ERROR: requested WAL segment ... has already been removed.
Replication slots solve this by making the primary track each standby’s WAL consumption position explicitly and guarantee that no WAL the standby still needs is recycled. The primary will hold that WAL indefinitely — even through checkpoints, even across restarts.
Create a physical replication slot on the primary:
|
|
Then reference it in the standby’s postgresql.auto.conf:
|
|
Monitor slot health with:
|
|
This query tells you how much WAL the primary is keeping because of this slot. When active is true, the standby is connected and consuming. When active is false, the standby is offline — and WAL is accumulating.
This is the most dangerous aspect of replication slots. An offline standby with an active slot will cause WAL to pile up in pg_wal without limit. Production databases with heavy write loads can fill a 2 TB disk in hours. The primary will crash with FATAL: could not write to file "pg_wal/...": No space left on device — taking down the primary to protect an already-dead standby.
PostgreSQL 13 introduced max_slot_wal_keep_size as a safety valve:
|
|
When the limit is exceeded, PostgreSQL invalidates the slot. The standby will need to be rebuilt with pg_basebackup. Set this parameter. Do not run replication slots without it in production.
The choice between wal_keep_size and replication slots is a tradeoff between simplicity and guarantee:
wal_keep_sizeis a rolling buffer. It keeps a minimum amount of WAL regardless of standby state. It does not prevent WAL loss for a standby that falls behind by more than the buffer, but it also does not accumulate WAL unboundedly if a standby is offline.- Replication slots guarantee the standby will never lose WAL while connected, but require active monitoring of slot lag and
max_slot_wal_keep_sizeas a backstop.
In practice, use replication slots for standbys you depend on for HA — especially when combined with Patroni — and set max_slot_wal_keep_size aggressively.
Synchronous vs Asynchronous Replication
The default replication mode is asynchronous. The primary writes the WAL locally, sends it to standbys, and immediately confirms the commit to the client. If the primary crashes before the standby receives that WAL, those committed transactions are lost. The data loss window is the amount of WAL that was in-flight between the primary’s local flush and the standby’s disk write — typically sub-second on a LAN, but potentially several seconds on a loaded system or across a WAN link.
For most read-heavy applications with conventional durability requirements, asynchronous replication with a good monitoring setup is the right choice. For payment systems, financial ledgers, or any workload where losing a single committed transaction is unacceptable, you need synchronous replication.
Configuring Synchronous Replication
Set on the primary in postgresql.conf:
|
|
This instructs the primary to wait for at least one of standby1 or standby2 to acknowledge the WAL before confirming the commit to the client. If both standbys are up, standby1 is preferred (it is first in the list). If standby1 goes down, standby2 takes over as the synchronous standby automatically.
The synchronous_commit parameter on the primary (or per transaction) controls exactly what the standby must acknowledge:
|
|
remote_apply is the strongest setting: it ensures that when the commit returns to the client, the standby has not only received and written the WAL but also applied the changes to its data pages. This means reads on the standby immediately reflect the committed data — zero replication lag for reads — but it adds the highest latency to commits.
PostgreSQL 10+ Quorum Commit
PostgreSQL 10 introduced ANY N (...) syntax for quorum-based synchronous commit:
|
|
This waits for acknowledgment from any 2 of the 3 named standbys. The cluster can sustain one standby failure without blocking writes. This is the recommended pattern for high-availability setups that need zero data loss but cannot afford a single standby outage blocking the entire system.
The Latency Cost
Synchronous replication adds exactly one network round-trip to every commit. There is no way around this — you are waiting for a packet to cross the network to the standby and return. On a LAN with sub-millisecond RTT, this is often imperceptible. Across data centers with 5-20ms RTT, it will show up clearly in commit latency percentiles, and any workload that does many small single-row commits will feel it hard.
The classic mitigation is transaction batching — fewer, larger transactions mean fewer synchronous acknowledgments per second. Another approach is using remote_write instead of remote_apply, which requires the standby to write to the OS buffer but not fsync — this halves the acknowledgment latency at the cost of a narrow data loss window during a simultaneous standby OS crash.
One important operational note: if all synchronous standbys disconnect simultaneously (network partition, maintenance window), the primary will hang on every commit indefinitely waiting for an acknowledgment that will never come. This is the intended behavior — the primary is protecting against data loss — but it will look like a complete outage to your application. Always have a plan: either downgrade synchronous_commit to local temporarily, or ensure your quorum config can tolerate the expected failure scenario.
| Mode | Data Loss Risk | Write Latency Added | Complexity |
|---|---|---|---|
| Asynchronous | Sub-second WAL in-flight | None | Low |
remote_write |
Near-zero (OS buffer only) | +1 network RTT, no fsync wait | Medium |
remote_apply (on) |
Zero | +1 network RTT + apply time | Medium |
Quorum ANY 2 of 3 |
Zero | +1 network RTT (either 2 ack) | Medium-High |
| Sync, cross-DC | Zero (WAN) | +1 WAN RTT (10-50ms per commit) | High |
Logical Replication
Physical streaming replication is a single-primary to N-standby model where all standbys are byte-identical to the primary. Logical replication breaks that constraint at the cost of some capabilities.
Publisher (PostgreSQL 16) Subscriber (PostgreSQL 17)
+--------------------------+ +--------------------------+
| | | |
| WAL | | Apply worker process |
| | | | | |
| Logical decoding | decoded | Applies INSERTs, |
| (pgoutput plugin) +----------->| UPDATEs, DELETEs |
| | | row ops | to subscriber tables |
| Publication | | |
| (selected tables) | | Subscription |
| | | (connected publication) |
| Replication slot | | |
| (tracks subscriber LSN) | | Tables can differ in |
| | | columns, indexes, etc. |
+--------------------------+ +--------------------------+
|
pg_stat_replication
pg_publication_tables
Publisher Setup
The publisher must have wal_level = logical (a superset of replica). This enables the logical decoding infrastructure, which has a modest performance overhead — roughly 5-15% on write-heavy workloads depending on the workload mix. If you are running wal_level = replica today for streaming replication and want to add logical replication, changing wal_level requires a PostgreSQL restart.
|
|
A publication defines what changes are exported. A replication slot is created automatically when a subscription connects; this slot tracks the subscriber’s WAL consumption position so the publisher does not recycle WAL the subscriber still needs. The same slot-overflow risk from physical replication applies here — monitor logical replication slot lag just as carefully.
Subscriber Setup
|
|
On subscription creation, PostgreSQL performs an initial table sync — it copies all existing rows from the publisher tables to the subscriber tables using a temporary replication slot and a COPY operation. This can take hours for large tables. The subscription enters normal streaming mode only after the initial sync completes for all tables in the publication. Check progress:
|
|
Monitoring Logical Replication
On the publisher:
|
|
On the subscriber:
|
|
Limitations You Must Know Before Deploying
Logical replication does not replicate:
- DDL —
ALTER TABLE,CREATE INDEX,DROP COLUMNare not sent to the subscriber. You must apply schema changes to the subscriber manually before applying them to the publisher (for additive changes) or after (for destructive changes). Get this order wrong and replication breaks. - Sequences —
nextval()calls on the publisher do not advance the subscriber’s sequences. If you fail over to a subscriber, you must manually advance sequences past the last publisher value or risk primary key conflicts. - Large objects —
pg_largeobjectdata is not replicated. - TRUNCATE — supported from PostgreSQL 11, but requires the subscriber table to have no foreign key references from unreplicated tables.
PostgreSQL 16 brought parallel apply for large transactions, allowing subscribers to apply large transactions using multiple worker processes. PostgreSQL 17 added failover slot synchronization: logical replication slots can now be synchronized to standbys via pg_sync_replication_slots() so that on failover the logical replication subscriber can continue without reinitialization. This was a major operational pain point in PG 16 and earlier — a primary failover would break all logical replication subscriptions pointing at the old primary.
Zero-Downtime Major Version Upgrade with Logical Replication
The classic upgrade procedure for zero downtime uses logical replication between the old and new major versions:
- Stand up a new PostgreSQL instance (e.g., PG 17) alongside the existing PG 16 primary.
- Apply the same schema to PG 17 via
pg_dump --schema-onlyor your migration tool. - Set
wal_level = logicalon PG 16 if not already set (requires restart). - Create a publication on PG 16:
CREATE PUBLICATION upgrade_pub FOR ALL TABLES; - Create a subscription on PG 17:
CREATE SUBSCRIPTION upgrade_sub CONNECTION '...' PUBLICATION upgrade_pub; - Wait for initial sync to complete and for replication lag to approach zero.
- Take a brief maintenance window (typically seconds to under a minute): quiesce application writes, wait for lag to hit zero, verify row counts match on critical tables.
- Update the application’s connection string to point at PG 17.
- Drop the subscription on PG 17, decommission PG 16.
This procedure keeps the old primary fully available until the last moment. The only downtime is the connection string switch, which can be as fast as a HAProxy config reload. Compare this to pg_upgrade --link, which requires taking the old primary down entirely and carries risk of needing to roll back at a point where the old data directory has already been modified.
pg_basebackup In Depth
pg_basebackup is the standard tool for creating a consistent binary copy of a running PostgreSQL primary. It uses the replication protocol — it authenticates as a replication user, opens a replication connection, and streams data files while simultaneously streaming WAL changes that occur during the backup.
The full flag reference for production use:
|
|
For large databases (500 GB+), pg_basebackup can take hours. The backup is safe to run on a live primary — it uses an internal backup mode — but it will consume significant I/O bandwidth and one WAL sender slot. Schedule it during off-peak hours.
To create a compressed tar archive instead of a live data directory (useful for shipping to a backup server or another host):
|
|
This writes base.tar.gz (data directory) and pg_wal.tar.gz (WAL segments) to the destination. On the receiving end, extract and configure normally. For a standby built from a tar backup, you will need to add standby.signal and primary_conninfo manually since --write-recovery-conf cannot be used with tar format and a remote destination.
For compressing on the server side before transfer (PG 15+), which reduces network traffic for remote standbys:
|
|
Patroni for Automatic Failover
Manual failover — SSHing into a server, promoting a standby, updating connection strings, reconfiguring the old primary as a new standby — has two failure modes. The first is human reaction time: if your on-call engineer is asleep and the primary goes down at 3 AM, your database is down until they wake up. The second is split-brain: if the primary is only partially unavailable (a network partition where the primary can still reach some clients but not the monitoring system), promoting a standby leaves two nodes believing they are the primary. Both start accepting writes. Your data is now irreversibly diverged.
Patroni solves both problems. It is a Python daemon that wraps PostgreSQL and uses a Distributed Configuration Store (DCS) — etcd, Consul, or ZooKeeper — as an external arbiter. The fundamental invariant: there is exactly one leader lock in the DCS. Whoever holds that lock is the primary. PostgreSQL instances without the lock are standbys. The DCS, being a distributed consensus system, guarantees this invariant survives partial failures.
Architecture
+---------------------------------------------+
| etcd Cluster (3 nodes) |
| 10.0.1.10:2379 10.0.1.11:2379 10.0.1.12:2379 |
| (Raft consensus -- majority vote required) |
+--------+-------------------+-----------------+
| |
+----------+-------+ +-------+----------+ +------------------+
| Patroni Node 1 | | Patroni Node 2 | | Patroni Node 3 |
| (Leader) | | (Replica) | | (Replica) |
| | | | | |
| PostgreSQL 17 | | PostgreSQL 17 | | PostgreSQL 17 |
| (primary) |<--+ (hot standby) | | (hot standby) |
| :5432 |WAL| streams WAL | | streams WAL |
| | | :5432 | | :5432 |
| REST API :8008 | | REST API :8008 | | REST API :8008 |
+------------------+ +------------------+ +------------------+
| | |
+--------+----------------------+----------------------+-------+
| HAProxy |
| |
| :5000 (writes) -- httpchk GET /primary -> HTTP 200 |
| routes only to the node Patroni marks as leader |
| |
| :5001 (reads) -- httpchk GET /replica -> HTTP 200 |
| routes to standbys (round-robin) |
+--------------------------------------------------------------+
|
Application clients
The etcd cluster is separate from the PostgreSQL nodes — typically 3 or 5 nodes running only etcd. Patroni connects to etcd to acquire and renew the leader lock. The lock has a TTL (typically 30 seconds). Every loop_wait seconds (typically 10 seconds), the leader’s Patroni daemon renews the lock. If the primary node fails, the lock expires after one TTL, and a standby’s Patroni daemon acquires the lock and promotes its PostgreSQL instance.
As of Patroni 4.1.3 (the current release as of May 2026), etcd 3.5.x is the recommended DCS backend. The etcd3 configuration key in patroni.yml uses etcd’s v3 protocol, which Patroni 4.x declares production-ready. Avoid mixing etcd protocol versions in the same Patroni cluster: keys written with the v2 protocol are invisible to v3 clients and vice versa.
patroni.yml Configuration
|
|
The watchdog section deserves attention. Fencing is how Patroni prevents split-brain: when a node loses the leader lock (because it failed to renew), Patroni immediately calls pg_ctl stop. But what if PostgreSQL is so hung that pg_ctl stop does not return? The watchdog device — a kernel-level timer that must be periodically reset — provides a hardware-level guarantee: if Patroni stops resetting the watchdog (because Patroni itself is hung), the kernel will reset the machine. This ensures the old primary is truly dead before the new one starts accepting writes.
patronictl Commands
The patronictl command-line tool is your interface to the cluster state. Every DBA managing a Patroni cluster should have these memorized.
Check cluster health:
|
|
TL is the timeline number — it increments on every failover. All replicas should be on the same timeline as the leader. A replica on a lower timeline has not yet caught up from the last failover.
Planned switchover (zero data loss, for maintenance):
|
|
Patroni waits for the candidate standby to fully catch up, then atomically demotes the primary and promotes the standby. The operation takes a few seconds and is typically invisible to read queries. Write queries will see a brief pause.
Forced failover (when the primary is completely dead and cannot be recovered):
|
|
Use failover only when the primary is confirmed dead. Unlike switchover, it does not wait for the candidate to be fully caught up — it promotes the standby at its current position. If the standby was lagging, those transactions are lost. For asynchronous setups, this is an accepted tradeoff in exchange for faster recovery.
Reinitialize a broken replica that cannot rejoin via streaming:
|
|
This wipes the standby’s data directory and runs pg_basebackup from the current leader. Use when a replica’s WAL diverged beyond what streaming can repair, or when pg_rewind fails.
Pause automatic failover for maintenance:
|
|
Edit cluster-wide DCS configuration:
|
|
This opens the cluster-wide Patroni configuration stored in etcd in your $EDITOR. Changes here propagate to all cluster members without restarting Patroni. Use this to change maximum_lag_on_failover, synchronous_standby_names, or PostgreSQL parameters that Patroni manages.
pg_rewind: Rejoining a Failed Primary
After a failover, the old primary is in an awkward state. It has a WAL history that diverged from the new primary at the moment of failover. It cannot simply be started as a standby pointing at the new primary — its data files reflect a timeline the new primary no longer recognizes.
The naive solution is pg_basebackup: rebuild the old primary from scratch as a new standby. This works, but for a 1 TB database it takes hours and generates significant network traffic during that time.
pg_rewind is the efficient alternative. It identifies the point at which the two timelines diverged, copies only the data blocks that changed on the new primary after divergence, and rewinds the old primary’s data directory to a consistent state from which streaming replication can take over. For a typical failover where the divergence point is seconds or minutes in the past, pg_rewind completes in minutes rather than hours.
Timeline before failover:
Old Primary ────────────────────── X (crashed)
|
| divergence point
|
Standby ─────────────────────-+
promoted --> new primary, TL 2
New primary (TL 2) ──────────────────────────────────────>
Old primary has data blocks from TL 1 past the divergence point.
pg_rewind replaces those blocks with the new primary's versions.
Streaming replication then catches up from the divergence point.
Requirements
pg_rewind requires that one of the following was enabled before the failure on the old primary:
wal_log_hints = oninpostgresql.conf, or- Data checksums enabled at
initdbtime (initdb --data-checksums)
Both cause PostgreSQL to write full page images on the first modification of a page after a checkpoint. pg_rewind needs these full page images to identify and copy changed blocks. If neither is enabled, pg_rewind will fail with an error about missing full-page writes.
In PostgreSQL 17, wal_log_hints defaults to off and data checksums default to off (PostgreSQL 18 will default data checksums to on). This means you need to explicitly enable one of them. Patroni’s bootstrap configuration handles this automatically when use_pg_rewind: true is set — it adds wal_log_hints = on to the PostgreSQL parameters. If you are setting up replication manually without Patroni, add wal_log_hints = on now, before you ever need pg_rewind.
PostgreSQL 17 also added a --sync-method option to pg_rewind. The default is fsync, which recursively opens and synchronizes all files in the data directory after rewinding. On Linux, syncfs can be used instead to ask the operating system to synchronize the whole filesystem, which can be faster for large data directories.
You also need a user with permission to read changed blocks from the new primary. Patroni configures this via the rewind authentication block. For manual setups, grant it explicitly in PostgreSQL 14+:
|
|
Running pg_rewind Manually
The old primary must be shut down before running pg_rewind. Starting from PostgreSQL 14, if the old primary did not shut down cleanly (e.g., it was killed or OOM-killed), pg_rewind will start it in single-user mode to complete crash recovery first before rewinding:
|
|
When Patroni is managing the cluster, you rarely need to run pg_rewind manually. Patroni detects the timeline mismatch automatically when the old primary comes back online, runs pg_rewind against the current leader, and reintegrates the node as a standby. This is the main benefit of use_pg_rewind: true in the bootstrap configuration.
When pg_rewind Cannot Help
pg_rewind requires that the WAL of the new primary since the divergence point is still available — either in pg_wal on the new primary or via a WAL archive configured as restore_command. If the new primary has been running for an extended period after the failover and has recycled its WAL, and no WAL archive is configured, pg_rewind will fail. In this case, fall back to pg_basebackup.
This is another compelling reason to configure a WAL archive alongside streaming replication. Whether you use pgBackRest, WAL-G, or a simple archive_command to a remote filesystem, the archive is a permanent record of all WAL that gives pg_rewind a fallback when the new primary’s live pg_wal is gone.
Monitoring Replication Lag
Replication lag has two distinct meanings that get conflated: bytes of WAL the standby has not yet received or applied, and wall-clock time since those bytes were generated. Both matter, but for different reasons. Byte lag tells you how much data would be lost if the primary failed right now. Time lag tells you how stale the standby’s data is for read queries.
Primary-Side Monitoring
The primary has full visibility into all connected standbys via pg_stat_replication:
|
|
Standby-Side Monitoring
From the standby itself (useful when you cannot query the primary, or for monitoring the standby’s own perspective):
|
|
Diagnosing the Bottleneck
The three lag columns in pg_stat_replication tell you where in the pipeline the standby is falling behind:
write_lagconsistently greater than zero: the network is the bottleneck. WAL is sitting in the primary’s send buffer waiting for the network path to the standby. Check network bandwidth and latency between primary and standby.flush_lagsignificantly larger thanwrite_lag: the standby’s disk is the bottleneck. WAL is arriving but not being fsync’d fast enough. Check standby I/O wait and disk saturation.replay_lagsignificantly larger thanflush_lag: the standby’s CPU or I/O is saturated with applying changes. This happens when the standby has heavy read traffic competing with WAL replay, or when many parallel writers on the primary generate WAL faster than the standby’s single recovery process can replay. Physical replication does not have parallel WAL apply; if this is your sustained bottleneck, consider a more powerful standby or reducing read load.
Replication Slot Monitoring
Monitor slot lag separately from pg_stat_replication — a slot for an offline standby is invisible in pg_stat_replication because there is no active connection, but WAL is silently accumulating:
|
|
Alert on wal_retained exceeding 5 GB. Alert immediately on active = false for any slot that is supposed to be serving a live standby.
Prometheus and postgres_exporter
The community postgres_exporter exposes all the above as Prometheus metrics out of the box. Key metrics for alerting:
# Replay lag in seconds per standby
pg_stat_replication_replay_lag{application_name="standby1"}
# Whether this instance is in recovery (standby)
pg_recovery_is_in_recovery
# WAL retained by replication slots (bytes)
pg_replication_slots_pg_wal_lsn_diff
# Active status of replication slots (1=active, 0=inactive)
pg_replication_slots_active
Recommended Prometheus alert thresholds:
| Alert | Condition | Severity |
|---|---|---|
| Replication lag high | replay_lag > 60s on any standby |
Warning |
| Replication lag critical | replay_lag > 300s on any standby |
Critical |
| Slot inactive | active = false on any expected-active slot |
Warning |
| Slot WAL accumulation | Slot wal_retained > 5 GB |
Critical |
| Phantom primary | pg_is_in_recovery = false on expected standby |
Critical |
| No standbys connected | Count of rows in pg_stat_replication = 0 |
Critical |
HAProxy and Connection Routing
A correct HA setup is incomplete without a layer in front of PostgreSQL that routes connections to the current primary. Clients must not have the primary’s IP hardcoded — after a failover, the IP changes, and every application would need reconfiguration.
Patroni’s REST API is designed specifically to integrate with HAProxy. Each Patroni node exposes HTTP endpoints:
GET /primary— returns200 OKif this node is the primary,503otherwiseGET /replica— returns200 OKif this node is a replica (not the primary),503otherwiseGET /health— returns200 OKif this node is healthy (primary or replica)GET /read-only— returns200 OKif this node is healthy and can serve reads
HAProxy health checks poll these endpoints and route traffic accordingly:
# /etc/haproxy/haproxy.cfg
global
maxconn 1000
defaults
log global
mode tcp
retries 3
timeout connect 5s
timeout client 30s
timeout server 30s
timeout check 5s
#
# Write port: routes to current primary only
#
frontend pg_write
bind *:5000
default_backend pg_primary
backend pg_primary
option httpchk GET /primary
http-check expect status 200
default-server inter 3s fastinter 1s fall 3 rise 2 on-marked-down shutdown-sessions
server pg-node1 10.0.0.10:5432 check port 8008
server pg-node2 10.0.0.11:5432 check port 8008
server pg-node3 10.0.0.12:5432 check port 8008
#
# Read port: routes to replicas (excludes primary)
#
frontend pg_read
bind *:5001
default_backend pg_replicas
backend pg_replicas
balance roundrobin
option httpchk GET /replica
http-check expect status 200
default-server inter 3s fastinter 1s fall 3 rise 2
server pg-node1 10.0.0.10:5432 check port 8008
server pg-node2 10.0.0.11:5432 check port 8008
server pg-node3 10.0.0.12:5432 check port 8008
With this configuration, your application uses port 5000 for writes and port 5001 for reads. HAProxy polls the Patroni REST API every 3 seconds. After a failover, HAProxy detects the old primary’s /primary endpoint returning 503 and the new primary’s returning 200, and reroutes write traffic within one check interval — typically under 10 seconds from the failover event.
Virtual IP Alternative
For simpler deployments with a single write endpoint and no read distribution, a Virtual IP (VIP) is an attractive alternative to HAProxy. Patroni supports on_role_change callbacks:
|
|
The promote-vip.sh script adds the VIP to the network interface when this node becomes primary and removes it when it becomes a replica:
|
|
This is simpler than HAProxy but provides no read distribution and no connection multiplexing. Keepalived with VRRP is a more production-hardened VIP management approach for environments where ARP announcements need to be handled more robustly.
DNS-Based Routing
For environments where HAProxy and VIPs are not options (some cloud environments, Kubernetes with external PostgreSQL), DNS-based routing can work: a short-TTL DNS record (pgwrite.internal pointing to the current primary IP) is updated by Patroni’s callback scripts on failover. The disadvantage is TTL propagation delay — 30-60 seconds of misrouting during failover — and the fact that many PostgreSQL drivers and connection pools resolve DNS once at connection open and do not re-resolve until the connection is closed. Use with caution and short TTLs (10-30 seconds maximum).
Production Operational Notes
A few hard-learned lessons that do not fit cleanly into any other section:
Test your failover before you need it. Run patronictl switchover quarterly. A failover mechanism you have never exercised in production is not a failover mechanism; it is a hope. Patroni switchovers are non-destructive and take less than 30 seconds in a healthy cluster. Do them regularly. Know what your application does during the brief write pause, and verify that clients reconnect automatically.
Replication lag during maintenance. If you are applying OS patches, taking a standby offline temporarily, or running a large operation on the primary, replication lag will spike. A VACUUM FULL on a large table generates enormous WAL; prefer pg_repack for production table bloat reclamation. Have a runbook that defines acceptable lag thresholds and what to do when they are breached.
Connection pooling. HAProxy provides connection routing, but it is not a connection pool. A heavily connected application should sit behind PgBouncer in transaction mode on each PostgreSQL node, with HAProxy routing to PgBouncer instances rather than directly to PostgreSQL. PgBouncer is ignorant of primary/replica state; HAProxy handles that routing; PgBouncer handles the N:M connection multiplexing. The two tools compose cleanly.
etcd sizing. The etcd cluster is the brain of your PostgreSQL HA setup. Run it on separate nodes from PostgreSQL — never colocate etcd with your database nodes, because a disk-full event or OOM on a PostgreSQL node should not take etcd with it. etcd is lightweight in CPU and memory, but it is latency-sensitive: run it on fast SSDs and keep network latency between etcd nodes under 10ms. Use a 3-node etcd cluster for single-DC deployments; use 5 nodes for multi-AZ deployments where you want to tolerate 2 simultaneous node failures.
Backup is not replication. Physical standbys protect against instance failure. They do not protect against logical errors: an accidental DROP TABLE, a bulk DELETE without a WHERE clause, or an application bug that corrupts rows. Those changes replicate instantly to all standbys. You need a separate backup solution — pgBackRest or WAL-G — that provides point-in-time recovery. Replication and backup solve different problems. Both are required for a complete data protection strategy.
The pg_stat_replication view lies by omission. It only shows currently-connected standbys. An offline standby with a replication slot does not appear here — but its slot is still accumulating WAL. Always query pg_replication_slots separately. The combination of an inactive slot and no max_slot_wal_keep_size limit has caused primary crashes in production more than once. Monitor both views, alert on both.
PostgreSQL’s replication infrastructure has reached a level of maturity where building a production HA cluster is genuinely tractable with open-source tools: pg_basebackup for initial standby creation, streaming replication for continuous WAL delivery, Patroni 4.x with etcd 3.5.x for automatic failover, and pg_rewind for efficient reintegration of a failed primary. The complexity is real — etcd adds operational overhead, replication slots add a failure mode, synchronous replication adds latency — but the building blocks are well-documented and the operational patterns are well-established.
What separates a solid HA deployment from a shaky one is not the initial setup but the ongoing operations: monitoring that alerts early, runbooks that have been actually exercised, backup infrastructure that is tested regularly, and a team that has done a failover drill and knows how long each step takes. Build the automation, then prove it works before 3 AM makes you prove it under pressure.
Comments