OnCallReady

Lesson 25.52 · PostgreSQL Operations · 28 min read

Replication and failover

In plain words

A replica is an understudy in a theatre: it watches every move the lead actor makes (it replays the WAL) and can step on stage the moment the lead falls ill (promotion). The danger is the night the lead recovers mid-show and walks back on stage while the understudy is still playing: two people speaking the same lines - split brain.

A replication slot is a promise the lead makes to wait for the understudy to keep up - a promise that becomes a problem if the understudy quit months ago.

Why this lesson exists

Production PostgreSQL almost always runs with at least one replica: for failover when the primary dies, for read traffic, for backups taken off the primary. Replicas bring their own pages: "the replica is 20 minutes behind", "the primary's disk is full of WAL kept for a replica that was deleted months ago", "we promoted the replica and now both think they are the primary". This lesson builds a streaming replica on the lab box, measures its lag, and walks through slots, promotion and the split-brain trap.

What you need to know already: WAL, LSNs, base backups and standby.signal / recovery.signal (the last lesson), pg_hba.conf and the replication keyword (lesson 4), clusters and ports with pg_lsclusters (lesson 2).

The words you need first

Building a replica

On the primary: a role that may replicate, and a pg_hba.conf rule for it (replication is a keyword - all does not match it). On this box the replica is a second cluster, 18/replica on port 5433; on a real setup it is another machine with the same steps.

$ sudo -u postgres psql -c "create role replicator with login replication password 'repl-lab-pw'"
CREATE ROLE
$ echo "host    replication     replicator      127.0.0.1/32            scram-sha-256" | sudo tee -a /etc/postgresql/18/main/pg_hba.conf
host    replication     replicator      127.0.0.1/32            scram-sha-256
$ sudo systemctl reload postgresql@18-main
$ sudo -u postgres psql -c "select name, setting from pg_settings where name in ('wal_level', 'max_wal_senders', 'max_replication_slots', 'hot_standby')"
         name          | setting
-----------------------+---------
 hot_standby           | on
 max_replication_slots | 10
 max_wal_senders       | 10
 wal_level             | replica
(4 rows)

The defaults (wal_level = replica, 10 WAL senders, 10 slots, hot_standby = on) are ready for replication. Now the replica: create the cluster's config, replace its empty data directory with a base backup taken from the primary over the replication protocol:

$ sudo pg_createcluster 18 replica -p 5433 > /dev/null
$ sudo rm -rf /var/lib/postgresql/18/replica
$ sudo -u postgres env PGPASSWORD=repl-lab-pw pg_basebackup -h 127.0.0.1 -p 5432 -U replicator -D /var/lib/postgresql/18/replica -R -C -S replica1 -X stream -c fast -P
56452/56452 kB (100%), 1/1 tablespace
$ sudo -u postgres cat /var/lib/postgresql/18/replica/postgresql.auto.conf
# Do not edit this file manually!
# It will be overwritten by the ALTER SYSTEM command.
primary_conninfo = 'user=replicator password=repl-lab-pw channel_binding=prefer host=127.0.0.1 port=5432 sslmode=prefer sslnegotiation=postgres sslcompression=0 sslcertmode=allow sslsni=1 ssl_min_protocol_version=TLSv1.2 gssencmode=prefer krbsrvname=postgres gssdelegation=0 target_session_attrs=any load_balance_hosts=disable'
primary_slot_name = 'replica1'
$ sudo ls /var/lib/postgresql/18/replica/ | grep signal
standby.signal

-R wrote standby.signal and the primary_conninfo the replica connects with, -C -S replica1 created a replication slot for it on the primary, -X stream brought the WAL the copy needs. (Passwords in primary_conninfo end up in a file - in production use ~postgres/.pgpass on the replica instead.) Start it:

$ sudo pg_ctlcluster 18 replica start
$ pg_lsclusters
Ver Cluster Port Status          Owner    Data directory                 Log file
18  main    5432 online          postgres /var/lib/postgresql/18/main    /var/log/postgresql/postgresql-18-main.log
18  replica 5433 online,recovery postgres /var/lib/postgresql/18/replica /var/log/postgresql/postgresql-18-replica.log
$ sudo tail -n 4 /var/log/postgresql/postgresql-18-replica.log
2026-09-22 20:05:29.500 UTC [17448] LOG:  redo starts at 0/2A0AFE0
2026-09-22 20:06:26.900 UTC [17461] LOG:  started streaming WAL from primary at 0/2000000 on timeline 1
2026-09-22 20:06:26.900 UTC [17449] LOG:  consistent recovery state reached at 0/2A0AFE0
2026-09-22 20:06:26.900 UTC [17448] LOG:  database system is ready to accept read-only connections

online,recovery: running, replaying, and (with hot_standby) accepting reads.

Watching it

On the primary, one row per connected replica:

$ sudo -u postgres psql -x -c "select application_name, client_addr, state, sent_lsn, write_lsn, flush_lsn, replay_lsn, write_lag, flush_lag, replay_lag, sync_state from pg_stat_replication"
-[ RECORD 1 ]----+-----------
application_name | 18/replica
client_addr      | 127.0.0.1
state            | streaming
sent_lsn         | 0/2A0AFB8
write_lsn        | 0/2A0AFB8
flush_lsn        | 0/2A0AFB8
replay_lsn       | 0/2A0AFB8
write_lag        |
flush_lag        |
replay_lag       |
sync_state       | async

state = streaming is healthy (catchup while it catches up after a pause). The four LSNs follow the WAL through the replica: sent -> written -> flushed to its disk -> replayed (visible to queries). The *_lag columns are times, measured from recent commits. Lag in bytes:

$ sudo -u postgres psql -c "select application_name, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) as replay_lag_bytes from pg_stat_replication"
 application_name | replay_lag_bytes
------------------+------------------
 18/replica       | 0 bytes
(1 row)

On the replica:

$ sudo -u postgres psql -p 5433 -c "select pg_is_in_recovery(), pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn(), now() - pg_last_xact_replay_timestamp() as since_last_replayed"
 pg_is_in_recovery | pg_last_wal_receive_lsn | pg_last_wal_replay_lsn | since_last_replayed
-------------------+-------------------------+------------------------+---------------------
 t                 | 0/2A0AFB8               | 0/2A0AFE0              |
(1 row)
$ sudo -u postgres psql -p 5433 -c "select status, sender_host, sender_port, slot_name from pg_stat_wal_receiver"
  status   | sender_host | sender_port | slot_name
-----------+-------------+-------------+-----------
 streaming | 127.0.0.1   |        5432 | replica1
(1 row)
$ sudo -u postgres psql -p 5433 -d orders -c "update orders set status = 'x' where id = 1"
ERROR:  cannot execute UPDATE in a read-only transaction

Writes are refused: cannot execute UPDATE in a read-only transaction. Note now() - pg_last_xact_replay_timestamp() grows on an idle primary (nothing to replay), so it is a lag signal only when the primary is busy - prefer the primary-side LSN difference.

What makes a replica fall behind:

causewhere you see itfix
the walreceiver cannot connect (password, hba, network, primary gone)replica log: could not connect to the primary server; no row in pg_stat_replicationthe connection
a long query on the replica blocks replay (conflict)replay_lsn stuck while flush_lsn moves; replica log canceling statement due to conflict with recoverymax_standby_streaming_delay, move the query, or hot_standby_feedback (costs bloat on the primary)
replay paused (pg_wal_replay_pause()) or delayed (recovery_min_apply_delay)pg_is_wal_replay_paused()pg_wal_replay_resume()
more write load than the replica's disk/CPU can replayall lags grow steadilya bigger replica, fewer writes
network bandwidthsent_lsn far behind pg_current_wal_lsn()the network, wal_compression

Replication slots

$ sudo -u postgres psql -c "select slot_name, slot_type, active, restart_lsn, wal_status, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) as retained from pg_replication_slots"
 slot_name | slot_type | active | restart_lsn | wal_status | retained
-----------+-----------+--------+-------------+------------+----------
 replica1  | physical  | t      | 0/2A0AFB8   | reserved   | 0 bytes
(1 row)

A slot guarantees the primary keeps every WAL segment from its restart_lsn on - so a replica that was down for an hour can catch up without a new base backup. The catch: a slot whose replica never comes back keeps WAL forever. Stop the replica and write some data:

$ sudo pg_ctlcluster 18 replica stop
$ for i in 1 2 3; do sudo -u postgres psql -d orders -qc "update orders set updated_at = now()"; done
$ sudo -u postgres psql -c "select slot_name, active, wal_status, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) as retained from pg_replication_slots"
 slot_name | active | wal_status | retained
-----------+--------+------------+----------
 replica1  | f      | reserved   | 30 MB
(1 row)
$ sudo du -sh /var/lib/postgresql/18/main/pg_wal
81M	/var/lib/postgresql/18/main/pg_wal

active = f, and the retained WAL grows with every write - checkpoints cannot remove it. With a replica gone for good, this is the "disk full on the primary" incident (one of this chapter's pages). The cure is to drop the slot (select pg_drop_replication_slot('replica1')) - and to put a ceiling on it in advance:

wal_status reads reserved (within max_wal_size), extended (kept beyond it because of the slot), unreserved (about to be removed) or lost. Bring the replica back so it catches up:

$ sudo pg_ctlcluster 18 replica start
$ sudo -u postgres psql -c "select slot_name, active, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) as retained from pg_replication_slots"
 slot_name | active | retained
-----------+--------+----------
 replica1  | f      | 30 MB
(1 row)

Failover

The primary is gone - a dead host, a broken disk. Promote the replica:

$ sudo pg_ctlcluster 18 main stop -m immediate
$ sudo pg_ctlcluster 18 replica promote
waiting for server to promote.... done
server promoted
$ sudo tail -n 4 /var/log/postgresql/postgresql-18-replica.log
2026-09-22 20:17:20.950 UTC [17513] LOG:  archive recovery complete
2026-09-22 20:17:20.950 UTC [17527] LOG:  checkpoint starting: force
2026-09-22 20:17:20.950 UTC [17512] LOG:  checkpoint complete: wrote 7 buffers (0.0%), wrote 0 SLRU buffers; 0 WAL file(s) added, 0 removed, 1 recycled; write=0.002 s, sync=0.001 s, total=0.017 s; sync files=7, longest=0.001 s, average=0.001 s; distance=1 kB, estimate=1 kB; lsn=0/2A0B1E0, redo lsn=0/2A0B1E0
2026-09-22 20:17:20.950 UTC [17512] LOG:  database system is ready to accept connections
$ sudo -u postgres psql -p 5433 -Atc "select pg_is_in_recovery()"
f
$ sudo -u postgres psql -p 5433 -d orders -c "update orders set status = 'paid' where id = 1"
UPDATE 1

selected new timeline ID: 2, database system is ready to accept connections, and writes work. (select pg_promote() from SQL does the same.) With async replication, whatever the old primary had committed but not yet sent is lost - that is the RPO of asynchronous replication.

Then the part that decides whether this was a failover or a disaster:

  1. The old primary must not come back as a primary. If it restarts (a reboot, systemd, a colleague) while apps still have its address, both servers take writes: split brain, and two diverging copies of your data. Fence it: stop it and make sure it stays stopped (echo disabled > /etc/postgresql/18/main/start.conf, systemctl mask, cut it off the network, or the cloud API stops the VM).
  2. Repoint the clients: a DNS name or virtual IP that moves, a proxy (HAProxy, PgBouncer), or connection strings with several hosts and target_session_attrs=read-write (libpq tries each until it finds the writable one).
  3. Rebuild redundancy: the old primary rejoins as a replica of the new one - with pg_rewind (rewinds its divergent changes; needs wal_log_hints or data checksums, on by default in 18) or a fresh base backup.
$ echo disabled | sudo tee /etc/postgresql/18/main/start.conf
disabled
$ pg_lsclusters
Ver Cluster Port Status Owner    Data directory                 Log file
18  main    5432 down   postgres /var/lib/postgresql/18/main    /var/log/postgresql/postgresql-18-main.log
18  replica 5433 online postgres /var/lib/postgresql/18/replica /var/log/postgresql/postgresql-18-replica.log

Doing all of that by hand at 3am is how mistakes happen. In production a manager does it with a consensus store so only one node can ever be primary: Patroni (etcd/Consul), pg_auto_failover, the Kubernetes operators (CloudNativePG, Crunchy PGO, Zalando), or the managed service's HA. Your job is to understand what it does so you can tell when it did the wrong thing.

In an interview: "The primary's disk is filling with WAL - what do you check?" - an inactive replication slot (pg_replication_slots with active = f and large retained WAL), a failing archive_command (pg_stat_archiver.failed_count), and checkpoints. Drop the stale slot (the replica it served needs a new base backup), and set max_slot_wal_keep_size so it cannot happen again. Never delete files from pg_wal by hand.

What you can do now

Why it helps

Production PostgreSQL almost always has replicas, for failover and read traffic, and they bring their own pages: lag, a primary disk full of WAL kept for a replica that no longer exists, and failovers that end with two primaries.

This lesson builds a replica, measures lag in bytes and time, shows slots retaining WAL, and walks through promotion, fencing and repointing clients - the parts that decide whether a failover is a non-event or a disaster.

Commands in this lesson

psql echo systemctl pg_createcluster rm env cat ls pg_ctlcluster pg_lsclusters tail du

FAQ

Why does the replication rule need its own pg_hba.conf line?

Replication connections are matched against the replication keyword in the database column; "all" deliberately does not include them. So a rule like host replication replicator 10.0.0.5/32 scram-sha-256 is needed, and the role needs the REPLICATION attribute. A missing rule shows in the replica's log as no pg_hba.conf entry for replication connection.

How do I measure replication lag reliably?

On the primary: pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) from pg_stat_replication gives bytes still to replay, and replay_lag gives time based on recent commits. On the replica, now() minus pg_last_xact_replay_timestamp() grows when the primary is idle too, so it is only a lag signal while the primary is writing.

Why is an inactive replication slot dangerous?

A slot makes the primary keep every WAL segment its consumer has not received. If the consumer is gone for good, the WAL piles up until the disk is full and the primary stops. Drop stale slots, set max_slot_wal_keep_size so a slot is given up before the disk fills, and alert on slots with active = false.

What happens to transactions the replica had not received when I promote it?

With asynchronous replication they are lost - that is the RPO of async replication, usually a fraction of a second but more under load or network trouble. Synchronous replication (synchronous_standby_names) makes commits wait for a standby, so nothing acknowledged is lost, at the cost of latency on every write.

How does the old primary come back after a failover?

Never as a primary. Fence it first (disable autostart, mask the unit, cut its network), then rejoin it as a replica of the new primary: pg_rewind rewinds its divergent changes quickly, or take a fresh base backup. HA managers such as Patroni or Kubernetes operators automate this with a consensus lock so only one node can be primary.

In an interview Mid

The primary's disk is filling with WAL. What do you check?

First replication slots: pg_replication_slots with active = false and a large pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) - a slot whose replica is gone keeps WAL forever. Then the archiver: pg_stat_archiver failed_count and "archive command failed" in the log, because unarchived WAL is kept too. Drop the stale slot (its replica will need a new base backup), fix archiving, CHECKPOINT so old segments are recycled, and set max_slot_wal_keep_size plus alerts on inactive slots. Never delete files from pg_wal by hand.

Also asked: How do you measure replication lag? · Walk through a safe failover to a replica. · What is split brain and how do you prevent it?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.