Replication & High Availability

Keeping databases online when hardware fails and traffic spikes.

From Single Point of Failure to Fault Tolerance

A single database server is a single point of failure. When it crashes, your application goes offline. Replication creates copies of your data across multiple servers, enabling high availability (survive failures) and horizontal scaling (distribute load). This lesson builds on Transactions & ACID from the Databases course and on Relational Databases at Scale, and it pairs with Partitioning & Sharding: replication copies the same data to more machines, sharding splits different data across them.

Real-World Impact:
  • GitHub (October 2018): a 43-second network partition was enough for Orchestrator to promote West Coast databases to primary. The resulting cross-country write path degraded service for 24 hours and 11 minutes, because GitHub chose to stay degraded rather than fail back and risk inconsistency. No user data was lost, but a few seconds of writes needed manual reconciliation.
  • Netflix: runs Cassandra with multi-primary replication across AWS regions, so the loss of an entire region is survivable.
  • Discord (May 2022): migrated trillions of messages from Cassandra to ScyllaDB, shrinking its largest cluster from 177 nodes to 72 and cutting p99 read latency from 40-125 ms to a steady 15 ms.
Try it locally (PostgreSQL 16)

Replication is one of the few database topics you cannot really learn from a single server, so this lesson runs against a real primary and a real replica. Every output on this page was captured from the pair below. Start them first, and each Python example further down will run on its own against them, with no shared setup file to import.

# A primary and a replica, to watch streaming replication for real.
docker network create pgrepl

docker run -d --name pg-primary --network pgrepl \
  -e POSTGRES_PASSWORD=demo -e POSTGRES_USER=demo -e POSTGRES_DB=demo \
  -p 5432:5432 postgres:16-alpine \
  -c wal_level=replica -c max_wal_senders=10 -c hot_standby=on

# The default pg_hba.conf only trusts "replication" connections from
# localhost. "host all all all ..." does NOT cover them (replication is
# not a database, so it is never matched by "all") - without this line
# pg_basebackup below fails with "no pg_hba.conf entry for replication
# connection". Verified against postgres:16-alpine.
docker exec pg-primary sh -c \
  "echo 'host replication all all scram-sha-256' >> /var/lib/postgresql/data/pg_hba.conf"
docker exec pg-primary psql -U demo -d demo -c "SELECT pg_reload_conf();"

# Base-backup the primary into the replica's data directory, then start it.
# chown/chmod are required here because we replace the image's own
# entrypoint command with a plain "sh -c", so nothing else prepares the
# (freshly created, root-owned) data directory for the postgres user.
docker run -d --name pg-replica --network pgrepl -p 5433:5432 \
  -e PGPASSWORD=demo postgres:16-alpine sh -c \
  "rm -rf /var/lib/postgresql/data/* && \
   chown postgres:postgres /var/lib/postgresql/data && \
   chmod 700 /var/lib/postgresql/data && \
   su-exec postgres pg_basebackup -h pg-primary -U demo -D /var/lib/postgresql/data -Fp -Xs -R && \
   exec su-exec postgres postgres"

# Clean up:  docker rm -f pg-primary pg-replica && docker network rm pgrepl

The Python examples need psycopg[binary] and psycopg_pool. Outputs on this page come from PostgreSQL 16.14, psycopg 3.3.4, psycopg_pool 3.3.1 and Python 3.12.13, with both containers on the same machine: timings over a real network will be larger, but every count and every ordering will be the same.

Replication Fundamentals

Replication copies data from one database server (source) to one or more servers (replicas). Changes on the source are propagated to replicas, keeping them in sync.

Why Replicate?
High Availability

If primary fails, promote replica to primary.

Uptime Goal: 99.99%
= 52 minutes downtime/year

Single server: 99.5%
= 43 hours downtime/year

With replication + failover:
Achieve 99.99% or higher
Read Scaling

Distribute read queries across replicas.

10,000 QPS, 90% reads
= 9,000 reads + 1,000 writes

Before: all 10,000 on primary

1 primary + 3 replicas:
  primary:  1,000 writes
  replicas: 3,000 reads each

Read capacity: 3x
Write capacity: unchanged
Geographic Distribution

Place replicas near users for low latency.

Primary: US East
Replica: EU West
Replica: Asia Pacific

EU users query EU replica:
50ms instead of 200ms

Latency reduced 4x
Replication Methods
MethodHow It WorksProsCons
SynchronousPrimary waits for replica to confirm before committingZero data loss, replicas always currentSlow writes (network latency), replica failure blocks writes
AsynchronousPrimary commits immediately, replicas catch up laterFast writes, replica failures don't block writesReplication lag (replicas behind), potential data loss on failure
Semi-SynchronousWait for at least one replica, others asyncBalance of speed and safetyStill some lag on async replicas
Choosing One in PostgreSQL

PostgreSQL is asynchronous until you name a standby in synchronous_standby_names. That single setting is the whole switch, and it takes only a reload, not a restart.

# Open a psql session inside the primary container. Everything below the
# next line is SQL typed at the resulting "demo=#" prompt. Use \q to exit.
docker exec -it pg-primary psql -U demo -d demo

-- The containers start empty, so create the table this example writes to.
-- It reaches the standby through replication, like any other change.
CREATE TABLE IF NOT EXISTS users (id serial PRIMARY KEY, username text);

-- PostgreSQL replicates asynchronously by default: sync_state reads
-- "async" and COMMIT never waits for a standby.
SELECT application_name, sync_state FROM pg_stat_replication;

-- Name the standbys that must acknowledge before COMMIT returns. The name
-- is matched against the standby's application_name, which defaults to
-- "walreceiver" unless primary_conninfo sets one.
ALTER SYSTEM SET synchronous_standby_names = 'walreceiver';
SELECT pg_reload_conf();

SELECT application_name, sync_state, sync_priority FROM pg_stat_replication;

-- Durability is per transaction, so the expensive guarantee can apply only
-- where it is worth paying for. This session opts out and does not wait:
SET synchronous_commit = off;
INSERT INTO users (username) VALUES ('analytics_import');

\q
Expected Output:
psql (16.14)
Type "help" for help.

demo=# CREATE TABLE IF NOT EXISTS users (id serial PRIMARY KEY, username text);
CREATE TABLE
demo=# SELECT application_name, sync_state FROM pg_stat_replication;
┌──────────────────┬────────────┐
│ application_name │ sync_state │
├──────────────────┼────────────┤
│ walreceiver      │ async      │
└──────────────────┴────────────┘
(1 row)

demo=# ALTER SYSTEM SET synchronous_standby_names = 'walreceiver';
ALTER SYSTEM
demo=# SELECT pg_reload_conf();
┌────────────────┐
│ pg_reload_conf │
├────────────────┤
│ t              │
└────────────────┘
(1 row)

demo=# SELECT application_name, sync_state, sync_priority FROM pg_stat_replication;
┌──────────────────┬────────────┬───────────────┐
│ application_name │ sync_state │ sync_priority │
├──────────────────┼────────────┼───────────────┤
│ walreceiver      │ sync       │             1 │
└──────────────────┴────────────┴───────────────┘
(1 row)

demo=# SET synchronous_commit = off;
SET
demo=# INSERT INTO users (username) VALUES ('analytics_import');
INSERT 0 1
demo=# \q

One setting and a reload moved sync_state from async to sync. Note the last two statements: synchronous_commit is a per-transaction setting, so a bulk import can opt out of the wait while everything else keeps the guarantee. Durability is a dial, not a switch.

What One Synchronous Standby Costs

Synchronous replication is usually described as a performance trade: safer writes, slower writes. That description is incomplete in a way that matters at three in the morning. The script below stops the standby and then tries to use the primary, which is the situation the setting was supposed to protect you from.

# sync_failure.sh - what a single synchronous standby costs when it dies.
# Self-contained: it makes the pair synchronous and builds its own table, so
# the numbers below are the same on every run and do not depend on anything
# else in this lesson you have already run.

echo "--- setup: make the pair synchronous, with exactly one committed row"
docker exec pg-primary psql -U demo -d demo -qtAc \
  "ALTER SYSTEM SET synchronous_standby_names = 'walreceiver';"
docker exec pg-primary psql -U demo -d demo -qtAc "SELECT pg_reload_conf();"
docker exec pg-primary psql -U demo -d demo -q \
  -c "SET client_min_messages = warning;" \
  -c "DROP TABLE IF EXISTS sync_demo;" \
  -c "CREATE TABLE sync_demo (id serial PRIMARY KEY, note text);" \
  -c "INSERT INTO sync_demo (note) VALUES ('committed while the standby was up');"

docker stop pg-replica

echo "--- write (10s timeout, because it will not return on its own):"
timeout 10 docker exec pg-primary psql -U demo -d demo \
  -c "INSERT INTO sync_demo (note) VALUES ('written while the standby is down');"
echo "exit status: $?"

echo "--- read, same server, same moment:"
timeout 10 docker exec pg-primary psql -U demo -d demo \
  -tAc "SELECT count(*) FROM sync_demo;"
echo "exit status: $?"

echo "--- restoring: bring the standby back"
docker start pg-replica

# Careful with the quorum form. synchronous_standby_names is matched against
# each standby's application_name, and a list that matches NOTHING behaves
# exactly like the outage above: every write blocks, forever. This pair has
# one standby, called "walreceiver", so setting
#     'ANY 1 (replica1, replica2)'
# here would wedge the primary for the rest of the lesson. That form belongs
# in production, where replica1 and replica2 actually exist. Go back to
# asynchronous instead.
docker exec pg-primary psql -U demo -d demo -qtAc \
  "ALTER SYSTEM SET synchronous_standby_names = '';"
docker exec pg-primary psql -U demo -d demo -qtAc "SELECT pg_reload_conf();"

echo "--- rows now that the wait is over:"
docker exec pg-primary psql -U demo -d demo -tAc "SELECT count(*) FROM sync_demo;"
Expected Output:
--- setup: make the pair synchronous, with exactly one committed row
t
pg-replica
--- write (10s timeout, because it will not return on its own):
exit status: 124
--- read, same server, same moment:
1
exit status: 0
--- restoring: bring the standby back
pg-replica
t
--- rows now that the wait is over:
2

The INSERT prints nothing at all, and the exit status is 124: that is the timeout wrapper giving up, not PostgreSQL reporting anything. No error, no rollback, no log line. The COMMIT is still waiting for an acknowledgement that is never coming, and left alone it would wait forever. The read immediately after returns 1 with exit status 0, so the server answers queries perfectly normally while every write on it hangs.

So with exactly one synchronous standby, losing that standby does not slow writes down, it stops them. A machine added to improve availability has become a second machine that can take production down, and the failure presents as a hang rather than an error, which is the hardest kind to diagnose under pressure.

The last line is the detail that reframes the whole thing. Once the standby returns and the wait is released, the count is 2: the write that appeared to fail was never lost. It had been committed to local WAL from the beginning, and only the acknowledgement was outstanding. Synchronous replication does not make a commit conditional, it makes it invisible until a standby confirms, which is why the symptom is a hang rather than an error.

The right answer in production is the quorum form, ANY 1 (replica1, replica2): any one of several standbys can satisfy the wait, so you keep zero data loss on commit without making a single standby a hard dependency of the primary. Read the comment in the restore step before you try it here, though. The names are matched against each standby's application_name, and a list that matches nothing blocks writes exactly like the outage above. This pair has one standby named walreceiver, so setting those two names on it would wedge the primary for the rest of the lesson, which is why the script restores asynchronous replication instead.

Note also that synchronous does not mean replicas are instantly readable. The default level, synchronous_commit = on, waits for the standby to write and flush the WAL, not to replay it. A committed row can still be missing from a read on that standby for a moment. Only remote_apply waits for replay, and it is the slowest setting of all. Most production systems stay asynchronous and design around eventual consistency, which is exactly what the rest of this lesson does.

Primary-Replica Replication

Also called leader-follower. One primary accepts writes, multiple replicas accept reads. This is the most common replication topology.

Architecture

Primary-Replica Topology (with Failover)

ApplicationPrimaryaccepts writesReplica 1readsReplica 2reads / standbyReplica 3readswritesWAL streamreads (load balanced)

Figure 1: Writes go to the primary and stream to replicas via the WAL (async by default). Reads spread across replicas. If the primary fails, a replica (e.g. Replica 2) is promoted to become the new primary.

Step 1: Configuring the Primary

The primary needs three things: a WAL detailed enough to rebuild a replica from, enough sender processes to serve every replica at once, and a pg_hba.conf rule that permits replication connections specifically. Which settings need a restart and which need only a reload decides how you apply each one, and in a container it decides whether the setting goes on the docker run command line at all.

# On the PRIMARY. Three server settings, one auth rule, one role.

# 1. wal_level, max_wal_senders and hot_standby need a RESTART, so the
#    container at the top of this lesson passes them on its command line:
#      -c wal_level=replica -c max_wal_senders=10 -c hot_standby=on
#    On a real server the same three go in postgresql.conf:
#      wal_level = replica     WAL detailed enough to rebuild a replica from
#      max_wal_senders = 10    One sender per connected replica, plus one
#                              MORE for each running pg_basebackup that uses
#                              "-X stream". Set it above your replica count.
#      hot_standby = on        Let replicas serve read-only queries

# 2. wal_keep_size needs only a reload, so change it the way you would change
#    any setting on a server you cannot restart: ALTER SYSTEM, then reload.
#    It is fallback WAL retention for replicas that fall behind; replication
#    slots (next block) do the same job precisely. If you use slots, set
#    max_slot_wal_keep_size too, so a replica that never comes back cannot
#    fill the primary's disk.
docker exec pg-primary psql -U demo -d demo -qtAc \
  "ALTER SYSTEM SET wal_keep_size = '1GB';"
docker exec pg-primary psql -U demo -d demo -qtAc "SELECT pg_reload_conf();"

# 3. A dedicated role. REPLICATION is a privilege that has to be granted
#    explicitly, and this role needs no access to any table. Written so that
#    re-running this script is harmless.
docker exec -i pg-primary psql -U demo -d demo -q <<'SQL'
DO $$
BEGIN
  IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'replicator') THEN
    CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'secure_password';
  END IF;
END $$;
SQL

# 4. pg_hba.conf. "replication" is a pseudo-database: a line with "all" in
#    the DATABASE column does NOT match replication connections. That is the
#    single most common reason pg_basebackup fails with "no pg_hba.conf entry
#    for replication connection". The setup at the top of this lesson already
#    added a permissive version of this line; in production scope it to the
#    replication role and your subnet:
#      # TYPE  DATABASE     USER        ADDRESS         METHOD
#      host    replication  replicator  192.168.1.0/24  scram-sha-256

# 5. Confirm what the server actually ended up using. The "source" column is
#    the one to read, not just "setting".
docker exec pg-primary psql -U demo -d demo \
  -c "SELECT name, setting, source FROM pg_settings
      WHERE name IN ('wal_level','max_wal_senders','wal_keep_size','hot_standby')
      ORDER BY name;"
Expected Output:
t
      name       | setting |       source       
-----------------+---------+--------------------
 hot_standby     | on      | command line
 max_wal_senders | 10      | command line
 wal_keep_size   | 1024    | configuration file
 wal_level       | replica | command line
(4 rows)

Always query the source column, not just setting. PostgreSQL takes configuration from several places with a fixed precedence, and the output above shows two of them at once: the three restart-only settings read back as command line, because that is where the container received them, while wal_keep_size reads as configuration file, because ALTER SYSTEM wrote it into postgresql.auto.conf.

That precedence has teeth. The command line outranks both postgresql.conf and ALTER SYSTEM, so on this container an ALTER SYSTEM SET max_wal_senders would be accepted, written to disk, survive a reload, and still never take effect. A setting that appears to have been ignored is nearly always this rather than a typo, and the source column is what tells you.

Step 2: Building the Replica

A replica starts life as a physical copy of the primary's data directory. You do not create the schema or restore a dump: pg_basebackup clones the whole cluster, and the replica then catches up by replaying WAL. The quick-start pair at the top of this lesson cloned the primary as demo with no replication slot, which is enough to get streaming working; this rebuilds it the way you would deploy it, as the dedicated replicator role with a slot of its own.

# On the REPLICA. The quick-start container at the top of this lesson cloned
# the primary as "demo" with no replication slot, which is fine for getting a
# pair running. Rebuild it the way you would in production: as the dedicated
# replicator role, with a slot of its own.

echo "--- replacing the quick-start replica"
docker rm -f pg-replica > /dev/null

# Slots outlive the replica that used them. That is exactly what they are
# for (the primary keeps WAL for a standby that is temporarily gone) and
# also their danger: an orphaned slot retains WAL forever and will fill the
# primary's disk. Since we are rebuilding, drop the old one, or the "-C"
# below fails with 'replication slot "replica1" already exists'.
docker exec pg-primary psql -U demo -d demo -qtAc \
  "SELECT pg_drop_replication_slot('replica1')
   WHERE EXISTS (SELECT 1 FROM pg_replication_slots WHERE slot_name = 'replica1');" \
  > /dev/null

# 1. pg_basebackup clones the primary's whole data directory. You do not
#    create a schema or restore a dump; the replica then catches up by
#    replaying WAL. There is no "systemctl start" afterwards: the server is
#    the container's main process, so the command ends by exec'ing it.
#    "su-exec postgres" drops from root first, because the server refuses to
#    run as root and this sh -c DOES run as root: passing a command other
#    than "postgres" makes the image entrypoint skip its own directory setup
#    and user switch, which is also why chown/chmod are needed above.
#    On a real server: the same pg_basebackup, then
#    "sudo systemctl start postgresql".
#
#    Flags:
#      -R          Write standby.signal AND the connection settings (step 3)
#      -X stream   Stream WAL during the backup, so the copy is consistent
#                  by the time the backup finishes
#      -C -S name  Create replication slot 'replica1' on the primary, so the
#                  primary retains WAL for this replica specifically instead
#                  of relying on wal_keep_size guesswork
docker run -d --name pg-replica --network pgrepl -p 5433:5432 \
  -e PGPASSWORD=secure_password postgres:16-alpine sh -c \
  "rm -rf /var/lib/postgresql/data/* && \
   chown postgres:postgres /var/lib/postgresql/data && \
   chmod 700 /var/lib/postgresql/data && \
   su-exec postgres pg_basebackup -h pg-primary -U replicator \
     -D /var/lib/postgresql/data -P -v -R -X stream -C -S replica1 && \
   exec su-exec postgres postgres" > /dev/null

sleep 10

# 2. -R created standby.signal. Its presence is what makes the server start
#    in standby mode. The file is empty; only the filename matters.
echo "--- standby.signal (its presence is what starts the server as a standby):"
docker exec pg-replica ls -l /var/lib/postgresql/data/standby.signal

# 3. -R ALSO wrote the connection settings, into postgresql.auto.conf. Do not
#    hand-write this file: ALTER SYSTEM rewrites it wholesale, and it says so
#    on its first line. Read it to confirm; change it with ALTER SYSTEM.
echo "--- postgresql.auto.conf (written by -R, not by you):"
docker exec pg-replica cat /var/lib/postgresql/data/postgresql.auto.conf

# 4. No start step: the container is already running as a standby. Confirm
#    it from both ends rather than assuming.
echo "--- is the replica in recovery?"
docker exec pg-replica psql -U demo -d demo -tAc "SELECT pg_is_in_recovery();"
echo "--- what the primary sees, and the slot it is now retaining WAL for:"
docker exec pg-primary psql -U demo -d demo \
  -c "SELECT application_name, state, sync_state FROM pg_stat_replication;" \
  -c "SELECT slot_name, slot_type, active FROM pg_replication_slots;"
Expected Output:
--- replacing the quick-start replica
--- standby.signal (its presence is what starts the server as a standby):
-rw-------    1 postgres postgres         0 Aug  2 14:07 /var/lib/postgresql/data/standby.signal
--- postgresql.auto.conf (written by -R, not by you):
# Do not edit this file manually!
# It will be overwritten by the ALTER SYSTEM command.
wal_keep_size = '1GB'
primary_conninfo = 'user=replicator password=secure_password channel_binding=prefer host=''pg-primary'' port=5432 sslmode=prefer 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'
--- is the replica in recovery?
t
--- what the primary sees, and the slot it is now retaining WAL for:
 application_name |   state   | sync_state 
------------------+-----------+------------
 walreceiver      | streaming | async
(1 row)

 slot_name | slot_type | active 
-----------+-----------+--------
 replica1  | physical  | t
(1 row)

There is no "start the server" step here, and that is a real difference rather than an omission. On a VM you would run pg_basebackup and then sudo systemctl start postgresql. A container has no init system: the server is the container's main process, so starting it is simply the last act of the container command, exec su-exec postgres postgres.

That last line earns some unpacking, because none of its three parts is decoration. The exec replaces the shell rather than spawning a child, so the server ends up as PID 1 and receives Docker's stop signals directly. The su-exec postgres drops from root to the postgres user, which is mandatory: the server refuses to start otherwise, with "root" execution of the PostgreSQL server is not permitted. And it has to be done by hand here because passing sh -c ... as the command bypasses the image's entrypoint logic entirely: that script only prepares directories and switches user when its first argument is postgres, so with any other command it just runs what you gave it, as root, on an unprepared data directory. The chown andchmod in the command exist for the same reason.

One thing this does not mean is that the replica is ready when the command returns. docker run -d returns as soon as the container starts, while the base backup still has to copy the cluster, which is what the sleep 10 covers. Waiting a fixed number of seconds is fine for a demonstration and wrong for anything else, which is why the final step checks readiness explicitly from both ends: pg_is_in_recovery() on the standby, and pg_stat_replication on the primary.

Notice also what step 3 did not ask you to do. Older guides tell you to hand-write primary_conninfo into postgresql.auto.conf, but -R has already written it, along with primary_slot_name, and the file opens with "Do not edit this file manually!" because ALTER SYSTEM rewrites it wholesale. Read that file to confirm the settings; change them with ALTER SYSTEM. The stray wal_keep_size line at the top of it is not a mistake either: it is the value set on the primary in step 2, copied over because a base backup clones postgresql.auto.conf along with everything else.

The slot is the part worth dwelling on. pg_replication_slots now shows replica1 as active, which means the primary will retain WAL for this replica specifically, however far behind it falls, instead of you guessing at a wal_keep_size. The same property is the danger: a slot belonging to a replica that is never coming back retains WAL forever and will fill the primary's disk, which is what max_slot_wal_keep_size is for. Slots also outlive their replica, so rebuilding a standby means dropping the old slot first, or the -C flag fails with "replication slot already exists".

One failure mode surprises people the first time: a standby refuses to start if max_connections, max_wal_senders,max_worker_processes, max_locks_per_transaction ormax_prepared_transactions is lower on the standby than on the primary. The log is explicit ("recovery aborted because of insufficient parameter settings"), and the fix is to raise the value on the standby and restart it.

Step 3: Verifying Replication Status

There are two views of lag, they disagree, and only one of them is safe to alert on.

# The two queries below run on DIFFERENT servers, so open a session on each.
# Both report on REPLAYED transactions, so on a pair that has never taken a
# write they come back empty: write something first, or you will misread a
# perfectly healthy idle pair as a broken one.

docker exec -it pg-primary psql -U demo -d demo

CREATE TABLE IF NOT EXISTS users (id serial PRIMARY KEY, username text);
INSERT INTO users (username) VALUES ('alice');

-- Which replicas are connected, and how far behind are they?
SELECT client_addr, state, sync_state, replay_lag FROM pg_stat_replication;


docker exec -it pg-replica psql -U demo -d demo

-- How long since this replica last replayed a transaction? Ask twice,
-- 15 seconds apart, with nobody writing to the primary in between.
SELECT now() - pg_last_xact_replay_timestamp() AS replication_lag;
SELECT pg_sleep(15);
SELECT now() - pg_last_xact_replay_timestamp() AS replication_lag;


# Then go back to the primary and ask IT the same question, after all that
# idling, to see how differently the two metrics behave.
docker exec -it pg-primary psql -U demo -d demo

SELECT client_addr, state, sync_state, replay_lag FROM pg_stat_replication;
Expected Output:
psql (16.14)
Type "help" for help.

demo=# CREATE TABLE IF NOT EXISTS users (id serial PRIMARY KEY, username text);
CREATE TABLE
demo=# INSERT INTO users (username) VALUES ('alice');
INSERT 0 1
demo=# SELECT client_addr, state, sync_state, replay_lag FROM pg_stat_replication;
┌─────────────┬───────────┬────────────┬─────────────────┐
│ client_addr │   state   │ sync_state │   replay_lag    │
├─────────────┼───────────┼────────────┼─────────────────┤
│ 172.24.0.3  │ streaming │ async      │ 00:00:00.001251 │
└─────────────┴───────────┴────────────┴─────────────────┘
(1 row)

demo=# \q

psql (16.14)
Type "help" for help.

demo=# SELECT now() - pg_last_xact_replay_timestamp() AS replication_lag;
┌─────────────────┐
│ replication_lag │
├─────────────────┤
│ 00:00:33.331732 │
└─────────────────┘
(1 row)

demo=# SELECT pg_sleep(15);
┌──────────┐
│ pg_sleep │
├──────────┤
│          │
└──────────┘
(1 row)

demo=# SELECT now() - pg_last_xact_replay_timestamp() AS replication_lag;
┌─────────────────┐
│ replication_lag │
├─────────────────┤
│ 00:00:52.532221 │
└─────────────────┘
(1 row)

demo=# \q

psql (16.14)
Type "help" for help.

demo=# SELECT client_addr, state, sync_state, replay_lag FROM pg_stat_replication;
┌─────────────┬───────────┬────────────┬────────────┐
│ client_addr │   state   │ sync_state │ replay_lag │
├─────────────┼───────────┼────────────┼────────────┤
│ 172.24.0.3  │ streaming │ async      │            │
└─────────────┴───────────┴────────────┴────────────┘
(1 row)

demo=# \q

Under 100 ms of lag is healthy; sustained lag above a second points at network trouble, a slow disk on the replica, or a replica busy serving long read queries.

The second query is the one to be careful with. now() - pg_last_xact_replay_timestamp() measures time since the last replayed transaction, which is not the same as lag. In the session above it reads 33 seconds and then 52 seconds, on a replica that was fully caught up the whole time and had nothing left to replay. The number was never measuring how far behind the replica was; it was measuring how long since anyone last wrote, which includes the time spent switching terminals. Alert on it and you will page someone every quiet night. pg_stat_replication.replay_lag is computed from WAL feedback instead, so it goes blank while idle rather than climbing, and reports real lag as soon as the next write arrives. Monitor the primary's replay_lag; treatpg_last_xact_replay_timestamp() as "time since the last write".

Read Replicas & Load Distribution

With primary-replica replication in place, direct read queries to replicas to offload the primary. This requires application-level routing logic.

Pattern 1: Routing Reads and Writes

The routing itself is small: one function that returns a primary connection, another that returns a replica connection. What matters is being able to prove it works, andpg_is_in_recovery() is how you do that. It returns false on a primary and true on a standby, so a single query tells you which kind of server you actually reached.

import random

import psycopg

# Both URLs point at the containers started at the top of this lesson.
# psycopg connects with libpq directly, so the plain "postgresql://" scheme
# is correct here. (The "postgresql+psycopg://" form is a SQLAlchemy-only
# spelling, needed there to pick psycopg 3 over psycopg2.)
PRIMARY = "postgresql://demo:demo@localhost:5432/demo"
REPLICAS = [
    "postgresql://demo:demo@localhost:5433/demo",
    # In production you list every replica here:
    #   "postgresql://demo:demo@replica2.example.com:5432/demo",
    #   "postgresql://demo:demo@replica3.example.com:5432/demo",
]


def get_write_conn():
    """Writes always go to the primary."""
    return psycopg.connect(PRIMARY)


def get_read_conn():
    """Reads go to a replica, picked at random to spread the load."""
    return psycopg.connect(random.choice(REPLICAS))


# pg_is_in_recovery() is the ground truth: it returns True on a standby and
# False on a primary. This is how you prove your routing actually works,
# rather than assuming it does.
with get_write_conn() as conn:
    in_recovery = conn.execute("SELECT pg_is_in_recovery()").fetchone()[0]
    print(f"get_write_conn() -> in recovery: {in_recovery}  (False = primary)")

with get_read_conn() as conn:
    in_recovery = conn.execute("SELECT pg_is_in_recovery()").fetchone()[0]
    print(f"get_read_conn()  -> in recovery: {in_recovery}   (True  = replica)")

# A replica is genuinely read-only. The error is worth seeing once, because
# it is exactly what a mis-routed write looks like in production.
try:
    with get_read_conn() as conn:
        conn.execute("CREATE TABLE should_not_work (id int)")
except psycopg.errors.ReadOnlySqlTransaction as exc:
    print(f"write sent to a replica -> {type(exc).__name__}: "
          f"{str(exc).strip().splitlines()[0]}")
Expected Output:
$ python routing.py
get_write_conn() -> in recovery: False  (False = primary)
get_read_conn()  -> in recovery: True   (True  = replica)
write sent to a replica -> ReadOnlySqlTransaction: cannot execute CREATE TABLE in a read-only transaction

The third line of that output is the safety net. A replica is genuinely read-only at the server level, so a write that is routed to it fails with ReadOnlySqlTransaction rather than silently going somewhere unexpected. Mis-routing is a loud bug, not a quiet one.

With those two helpers in place, ordinary application code just picks the right one for each operation. Note that DDL counts as a write: the table is created on the primary and arrives at the replica through replication like any other change.

import random
import time

import psycopg

PRIMARY = "postgresql://demo:demo@localhost:5432/demo"
REPLICAS = ["postgresql://demo:demo@localhost:5433/demo"]


def get_write_conn():
    return psycopg.connect(PRIMARY)


def get_read_conn():
    return psycopg.connect(random.choice(REPLICAS))


def wait_for_replica(timeout=10.0):
    """Block until the replica has replayed everything the primary has written.

    Compares the primary's current WAL position against the replica's last
    replayed position. This is the honest way to wait: sleeping for a fixed
    number of milliseconds only hides the race, it does not remove it.
    """
    with get_write_conn() as conn:
        target = conn.execute("SELECT pg_current_wal_lsn()").fetchone()[0]
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        with get_read_conn() as conn:
            replayed = conn.execute(
                "SELECT pg_last_wal_replay_lsn() >= %s", (target,)
            ).fetchone()[0]
        if replayed:
            return
        time.sleep(0.01)
    raise TimeoutError(f"replica did not reach {target} within {timeout}s")


def setup():
    """DDL is a write, so it goes to the primary. It reaches the replica the
    same way every other change does: through replication."""
    with get_write_conn() as conn:
        conn.execute("DROP TABLE IF EXISTS users")
        conn.execute("""
            CREATE TABLE users (
                id       serial PRIMARY KEY,
                username text NOT NULL UNIQUE,
                email    text NOT NULL
            )""")
        conn.commit()


def create_user(username, email):
    """Write operation -> primary."""
    with get_write_conn() as conn:
        conn.execute(
            "INSERT INTO users (username, email) VALUES (%s, %s)",
            (username, email),
        )
        conn.commit()


def get_users():
    """Read operation -> replica."""
    with get_read_conn() as conn:
        return conn.execute(
            "SELECT username, email FROM users ORDER BY id"
        ).fetchall()


setup()
create_user("alice", "alice@example.com")
create_user("bob", "bob@example.com")

wait_for_replica()
for username, email in get_users():
    print(f"read from replica: {username:6} {email}")
Expected Output:
$ python crud_routing.py
read from replica: alice  alice@example.com
read from replica: bob    bob@example.com

Be precise about what this buys you. Read capacity scales with the number of replicas, while write capacity does not improve at all, because every replica must still apply every write. Read replicas solve a read bottleneck. If your bottleneck is writes, you need sharding (Lesson 15), not more replicas.

Pattern 2: Read-After-Write Consistency

Now the interesting failure. Write to the primary, immediately read from a replica, and the row may not be there yet. On a laptop the replica is about a millisecond behind, so this race is almost never lost in development and testing, which is precisely why it reaches production. To make it deterministic, the example calls pg_wal_replay_pause(), which stops WAL replay on the replica and reproduces on demand what a slow network or an overloaded standby does at random.

import psycopg

PRIMARY = "postgresql://demo:demo@localhost:5432/demo"
REPLICA = "postgresql://demo:demo@localhost:5433/demo"


def setup():
    with psycopg.connect(PRIMARY) as conn:
        conn.execute("DROP TABLE IF EXISTS posts")
        conn.execute("""
            CREATE TABLE posts (
                id      serial PRIMARY KEY,
                user_id int  NOT NULL,
                content text NOT NULL
            )""")
        conn.commit()


def create_post_and_show(user_id, content):
    """Write to the primary, then immediately read from the replica."""
    with psycopg.connect(PRIMARY) as conn:
        post_id = conn.execute(
            "INSERT INTO posts (user_id, content) VALUES (%s, %s) RETURNING id",
            (user_id, content),
        ).fetchone()[0]
        conn.commit()

    with psycopg.connect(REPLICA) as conn:
        # Could be None: the replica may not have replayed this INSERT yet.
        return post_id, conn.execute(
            "SELECT id, user_id, content FROM posts WHERE id = %s", (post_id,)
        ).fetchone()


setup()

# On a loopback network the replica is usually a millisecond behind, so the
# race below almost never loses and the bug ships to production undetected.
# pg_wal_replay_pause() stops WAL replay on the replica, which turns that
# rare race into a guaranteed failure you can actually see and test against.
# It is the standby-side equivalent of a slow network or an overloaded disk.
with psycopg.connect(REPLICA) as conn:
    conn.execute("SELECT pg_wal_replay_pause()")
    conn.commit()
    print("replay state:", conn.execute(
        "SELECT pg_get_wal_replay_pause_state()").fetchone()[0])

post_id, post = create_post_and_show(1, "my first post")
print(f"primary wrote post id={post_id}")
print(f"replica returned:  {post}   <-- the user does not see their own post")

with psycopg.connect(REPLICA) as conn:
    conn.execute("SELECT pg_wal_replay_resume()")
    conn.commit()

with psycopg.connect(REPLICA) as conn:
    print("after resuming replay:",
          conn.execute("SELECT id, user_id, content FROM posts WHERE id = %s",
                       (post_id,)).fetchone())
Expected Output:
$ python stale_read.py
replay state: paused
primary wrote post id=1
replica returned:  None   <-- the user does not see their own post
after resuming replay: (1, 1, 'my first post')

The user posted something and their own post is not there. No exception was raised and nothing was logged: the query simply returned None, and resuming replay makes the row appear. This is the shape of most replication-lag bugs, which is why they are usually reported as "it sometimes doesn't save" rather than as an error.

The fix is read-your-writes consistency: the author reads back from the same primary connection that performed the write, and everyone else keeps reading from replicas. The version below runs with replay still paused, so it is tested under the conditions that broke the previous one rather than under ideal ones.

import psycopg

PRIMARY = "postgresql://demo:demo@localhost:5432/demo"
REPLICA = "postgresql://demo:demo@localhost:5433/demo"


def setup():
    with psycopg.connect(PRIMARY) as conn:
        conn.execute("DROP TABLE IF EXISTS posts")
        conn.execute("""
            CREATE TABLE posts (
                id      serial PRIMARY KEY,
                user_id int  NOT NULL,
                content text NOT NULL
            )""")
        conn.commit()


def create_post_and_show(user_id, content):
    """Read-your-writes: the author reads back from the same primary
    connection that performed the write, so replication lag cannot apply."""
    with psycopg.connect(PRIMARY) as conn:
        post_id = conn.execute(
            "INSERT INTO posts (user_id, content) VALUES (%s, %s) RETURNING id",
            (user_id, content),
        ).fetchone()[0]
        conn.commit()

        # Same connection, same server. Nothing to be stale about.
        return conn.execute(
            "SELECT id, user_id, content FROM posts WHERE id = %s", (post_id,)
        ).fetchone()


def get_feed():
    """Everyone else reads from the replica and tolerates a little staleness."""
    with psycopg.connect(REPLICA) as conn:
        return conn.execute(
            "SELECT id, user_id, content FROM posts ORDER BY id").fetchall()


setup()

# Pause replay again, to prove the fix holds under the exact conditions that
# broke the previous version rather than under ideal ones.
with psycopg.connect(REPLICA) as conn:
    conn.execute("SELECT pg_wal_replay_pause()")
    conn.commit()

print("author sees:", create_post_and_show(1, "my first post"))
print("replica feed while replay is paused:", get_feed())

with psycopg.connect(REPLICA) as conn:
    conn.execute("SELECT pg_wal_replay_resume()")
    conn.commit()
Expected Output:
$ python read_your_writes.py
author sees: (1, 1, 'my first post')
replica feed while replay is paused: []

The author sees their post; the replica-backed feed is still empty. That is the trade being made deliberately: the person who wrote the data gets a strong guarantee at the cost of one primary read, and everyone else gets cheap replica reads and finds out a moment later. Route by who is asking, not by which query it is.

Multi-Primary Replication

All nodes accept writes and propagate to others. Enables active-active deployment and geographic distribution. But introduces conflict resolution complexity.

Primary-Replica
Primary (writes)
   ↓  ↓  ↓
Replica Replica Replica
(reads) (reads) (reads)

Pros:

  • Simple, no write conflicts
  • Clear consistency model
  • Easy to reason about

Cons:

  • Primary is bottleneck for writes
  • Failover creates downtime
Multi-Primary
Primary 1 ←→ Primary 2
    ↕          ↕
Primary 3 ←→ Primary 4
(all accept reads + writes)

Pros:

  • No single point of failure
  • Geo-distributed writes
  • Zero-downtime failover

Cons:

  • Write conflicts must be resolved
  • Complex to operate
Write Conflicts in Multi-Primary

Everything above assumed one writer. Allow two, and a guarantee you have relied on since your first transaction quietly stops holding. The scenario below is not exotic: it is two ordinary updates to one row, each committing successfully on its own node.

-- Two users edit the same row on two different primaries at the same time.
-- Neither primary can see the other's in-flight work, so BOTH commits
-- succeed and no error is raised anywhere.

-- Primary 1 (US-East), 10:05:01
UPDATE users SET email = 'alice@usa.com'    WHERE id = 1;   -- COMMIT ok

-- Primary 2 (EU-West), 10:05:03
UPDATE users SET email = 'alice@europe.com' WHERE id = 1;   -- COMMIT ok

-- Replication now carries each change to the other node, and each node
-- applies the update it did not originate:
--
--   Primary 1 ends up with  alice@europe.com
--   Primary 2 ends up with  alice@usa.com
--
-- The two nodes now disagree permanently. Nothing failed, nothing was
-- rolled back, and no constraint was violated. That is the whole problem:
-- under multi-primary, "it committed" stops meaning "this is the value
-- everyone will converge on" unless you pick a conflict resolution policy.

No constraint was violated, nothing was rolled back, and neither user saw an error, yet the two nodes now hold different values for the same row and will not converge on their own. Under single-primary replication the primary serialises conflicting writes and one of them waits; there is no node in a multi-primary topology whose job that is. You must therefore choose a conflict resolution policy up front, because the database will otherwise choose divergence.

Conflict Resolution Strategies

Four broad approaches, in increasing order of how much they ask of the application:

StrategyHow It WorksExample
Last Write Wins (LWW)Timestamp-based, latest write keptalice@europe.com written at 10:05:03, alice@usa.com at 10:05:01 → Keep europe.com
Version VectorsTrack causality, detect true conflictsRiak uses version vectors to determine if writes are concurrent (Cassandra uses LWW instead)
Application-Level MergeStore both values, app decidesCouchDB returns conflicts, application merges them
CRDTsConflict-free data types with merge semanticsCounter CRDT: merge by summing increments from all nodes
Trade-off: Multi-primary is powerful but complex. Only use if you need geo-distributed writes or zero-downtime failover. For most applications, primary-replica with automatic failover is sufficient.

Automatic Failover Strategies

When the primary fails, a replica must be promoted to primary. Manual failover takes minutes; automatic failover takes seconds. But automation risks split-brain scenarios.

Failover Steps
  1. Detection: Monitor detects primary is unreachable (health checks fail for 10-30 seconds)
  2. Consensus: Remaining nodes agree primary is down (avoid false positives from network partitions)
  3. Election: Choose most up-to-date replica as new primary (least replication lag)
  4. Promotion: Convert replica to primary (remove read-only mode, accept writes)
  5. Reconfiguration: Point other replicas to new primary (update replication source)
  6. DNS Update: Update connection strings to point applications to new primary
Tool: Patroni for PostgreSQL

Patroni is the standard answer for PostgreSQL. The key design point is where the truth lives: cluster state is held in a distributed store (etcd, Consul or ZooKeeper), not in any database node. A node is the leader because it holds an expiring lock in that store and keeps renewing it, so a node that loses contact with the store loses leadership whether or not it is still running.

# patroni.yml, one per node. Patroni keeps the cluster state in a
# distributed store (etcd, Consul, ZooKeeper) and uses it for leader
# election, so the store, not any database node, is the source of truth.

scope: postgres-cluster        # every node in this cluster shares the scope
name: node1                    # must be unique per node

restapi:
  listen: 0.0.0.0:8008
  connect_address: 192.168.1.10:8008

# Use "etcd3", not "etcd". The plain "etcd" section speaks etcd's v2 API,
# which etcd disables by default from 3.4 onwards.
etcd3:
  hosts: etcd1:2379,etcd2:2379,etcd3:2379

bootstrap:
  # "dcs" settings are written to the distributed store on first boot and
  # then shared by every node, so editing this file later does not change
  # them: use "patronictl edit-config" instead.
  dcs:
    ttl: 30                    # leader lease; the leader key expires after
                               # this many seconds without a refresh, which
                               # is what starts a failover
    loop_wait: 10              # seconds between health check cycles
    retry_timeout: 10          # give up on a DCS/PostgreSQL call after this
    maximum_lag_on_failover: 1048576   # 1 MB. A replica further behind than
                                       # this is not eligible for promotion,
                                       # bounding how much data a failover
                                       # can lose.
  initdb:
    - encoding: UTF8
    - data-checksums

postgresql:
  listen: 0.0.0.0:5432
  connect_address: 192.168.1.10:5432
  data_dir: /var/lib/postgresql/data
  bin_dir: /usr/lib/postgresql/16/bin
  authentication:
    replication:
      username: replicator
      password: secure_password
    superuser:
      username: postgres
      password: secure_password
  parameters:
    max_connections: 100
    shared_buffers: 256MB

# Check it before deploying:  patroni --validate-config patroni.yml

Two details in that file are easy to get wrong. The section key etcd3 selects etcd's v3 API; the older etcd key speaks v2, which etcd itself disables by default from 3.4 onwards. And everything under bootstrap.dcs is written to the distributed store the first time the cluster starts and is shared by all nodes from then on, so editing this file afterwards changes nothing: use patronictl edit-config. The config above was checked with patroni --validate-config on Patroni 4.1.4.

The ttl, loop_wait and retry_timeout values are what actually set your failover time. With the defaults shown, a dead primary is detected and replaced in roughly 30 seconds; lowering them shortens the outage and raises the risk of a promotion triggered by a brief network hiccup.

Monitoring Failover Events

Patroni exposes cluster state over a REST API, so monitoring is a matter of polling one endpoint and deciding what deserves a page. The example below keeps the decision logic in a pure function that takes a payload and returns alerts, so it runs against a recorded response with no cluster attached, and can be unit tested the same way.

import json

# A real response from Patroni's REST API, GET http://<node>:8008/cluster.
# Keeping it as a literal makes the parsing below runnable and testable
# without a live cluster; swap in the requests call at the bottom for real use.
SAMPLE = """
{
  "members": [
    {"name": "node1", "role": "leader",  "state": "running",
     "api_url": "http://192.168.1.10:8008/patroni",
     "host": "192.168.1.10", "port": 5432, "timeline": 7},
    {"name": "node2", "role": "replica", "state": "streaming",
     "api_url": "http://192.168.1.11:8008/patroni",
     "host": "192.168.1.11", "port": 5432, "timeline": 7, "lag": 0},
    {"name": "node3", "role": "replica", "state": "streaming",
     "api_url": "http://192.168.1.12:8008/patroni",
     "host": "192.168.1.12", "port": 5432, "timeline": 7, "lag": 12582912}
  ]
}
"""

MAX_LAG_BYTES = 8 * 1024 * 1024  # alert above 8 MB of replication lag


def check_cluster(cluster_info):
    """Turn one /cluster payload into a list of alerts. A pure function, so
    it can be unit tested against recorded payloads like the one above."""
    alerts = []
    for member in cluster_info["members"]:
        # Only replicas report "lag"; the leader has no lag key at all, so
        # member["lag"] would raise KeyError on the leader.
        lag = member.get("lag")
        lag_text = "n/a" if lag is None else f"{lag / 1024 / 1024:.1f} MB"
        print(f"  {member['name']:6} role={member['role']:8} "
              f"state={member['state']:10} lag={lag_text}")
        if lag is not None and lag > MAX_LAG_BYTES:
            alerts.append(f"{member['name']} is {lag_text} behind the leader")

    leaders = [m for m in cluster_info["members"] if m["role"] == "leader"]
    if not leaders:
        alerts.append("no leader: failover in progress or the cluster is down")
    elif len(leaders) > 1:
        alerts.append(f"CRITICAL split-brain: {len(leaders)} leaders")

    # Patroni bumps the timeline on every promotion, so members left on an
    # older timeline have not caught up to the most recent failover.
    timelines = {m.get("timeline") for m in cluster_info["members"]}
    if len(timelines) > 1:
        alerts.append(f"members disagree on timeline: {sorted(timelines)}")
    return alerts


print("steady state, one replica falling behind:")
for alert in check_cluster(json.loads(SAMPLE)):
    print(f"  ALERT: {alert}")

print("\nsame cluster during a bad failover:")
broken = json.loads(SAMPLE)
broken["members"][1]["role"] = "leader"   # a second leader appears
broken["members"][2]["timeline"] = 6      # and node3 is on an old timeline
for alert in check_cluster(broken):
    print(f"  ALERT: {alert}")

print("\nand mid-failover, with no leader at all:")
headless = json.loads(SAMPLE)
for member in headless["members"]:
    member["role"] = "replica"
for alert in check_cluster(headless):
    print(f"  ALERT: {alert}")

# In production, replace the literal with a poll of the real endpoint:
#
#   import requests, time
#   while True:
#       payload = requests.get("http://localhost:8008/cluster", timeout=5).json()
#       for alert in check_cluster(payload):
#           send_to_pagerduty(alert)
#       time.sleep(5)
Expected Output:
$ python patroni_monitor.py
steady state, one replica falling behind:
  node1  role=leader   state=running    lag=n/a
  node2  role=replica  state=streaming  lag=0.0 MB
  node3  role=replica  state=streaming  lag=12.0 MB
  ALERT: node3 is 12.0 MB behind the leader

same cluster during a bad failover:
  node1  role=leader   state=running    lag=n/a
  node2  role=leader   state=streaming  lag=0.0 MB
  node3  role=replica  state=streaming  lag=12.0 MB
  ALERT: node3 is 12.0 MB behind the leader
  ALERT: CRITICAL split-brain: 2 leaders
  ALERT: members disagree on timeline: [6, 7]

and mid-failover, with no leader at all:
  node1  role=replica  state=running    lag=n/a
  node2  role=replica  state=streaming  lag=0.0 MB
  node3  role=replica  state=streaming  lag=12.0 MB
  ALERT: node3 is 12.0 MB behind the leader
  ALERT: no leader: failover in progress or the cluster is down

The three scenarios exercise every alert the function can raise, which is the point of keeping the logic in a pure function: you can test the conditions you hope never to see. Zero leaders means a failover is in progress, or the cluster is simply down. More than one means split-brain, the subject of the next section. Members on different timelines means a node has not caught up to the most recent promotion, since Patroni increments the timeline every time it promotes, and a node still on the old one will need to be rewound or rebuilt. Note also that lag is reported only for replicas: the leader carries no such key, so a bare member["lag"] would raise KeyError on exactly the node you most want your monitoring to survive.

The Split-Brain Problem

Network partition splits cluster into isolated groups. Each group thinks the other is dead and elects its own leader. Result: multiple primaries, causing data divergence and corruption.

How Split-Brain Happens

The cruelty of split-brain is that every node behaves correctly. A replica that cannot reach the primary genuinely cannot tell "the primary is dead" from "I cannot reach the primary", and the safe-looking response to a dead primary is to promote. Both sides of the partition below follow their rules exactly, and the cluster still ends up with two primaries.

A Network Partition Producing Two Primaries

US clientsEU clientsPrimaryUS-EastReplica 2EU-West, promotedReplica 1US-EastwriteswritesWALpartitioned: no WAL

Figure 2: EU-West cannot reach the primary, concludes it is dead, and promotes Replica 2. Both nodes now accept writes for the same rows, and the two histories diverge for as long as the partition lasts.

This is strictly worse than an outage. An outage stops accepting data; split-brain accepts data on both sides and then forces someone to decide, by hand and after the fact, which customers' writes to discard. Recovery is measured in hours or days, and some of it is unrecoverable.

Prevention: Quorum-Based Elections

The fix is arithmetic rather than engineering. Require a strict majority to hold leadership, and two disjoint groups can never both qualify, because two groups that each hold more than half the nodes would need more nodes than exist. The model below is small enough to run and check for yourself.

def quorum(cluster_size):
    """Smallest group that is guaranteed to be unique in any partition.

    Integer division floors, so this is floor(n/2) + 1: strictly more than
    half. Two disjoint groups can never both satisfy it.
    """
    return cluster_size // 2 + 1


def no_quorum_rule(group_size, cluster_size):
    """"I cannot reach the leader, so the leader is dead and I am promoting."
    Perfectly reasonable, and every isolated group reaches it independently.
    """
    return group_size >= 1


def quorum_rule(group_size, cluster_size):
    """Promote only with a strict majority of the WHOLE cluster behind you,
    not just a majority of the nodes you can still see."""
    return group_size >= quorum(cluster_size)


def simulate(cluster_size, partition):
    """partition is the sizes of the isolated groups, e.g. [2, 1]. Runs both
    rules over the same split so the difference is the rule, nothing else."""
    assert sum(partition) == cluster_size, "every node lands in some group"
    print(f"cluster of {cluster_size}, partitioned into groups of "
          f"{' and '.join(str(s) for s in partition)}"
          f"   (quorum = {quorum(cluster_size)})")
    for name, rule in (("no quorum", no_quorum_rule), ("quorum", quorum_rule)):
        outcomes = ["leader" if rule(s, cluster_size) else "read-only"
                    for s in partition]
        leaders = outcomes.count("leader")
        detail = ", ".join(f"{s}->{o}" for s, o in zip(partition, outcomes))
        flag = "   <-- SPLIT BRAIN" if leaders > 1 else ""
        print(f"  {name:9}  {detail:34} leaders: {leaders}{flag}")
    print()


# The classic 3-node cluster. Under the naive rule both sides promote and the
# data diverges; under quorum only the side holding two nodes may lead.
simulate(3, [2, 1])

# 5 nodes tolerate two simultaneous failures instead of one.
simulate(5, [3, 2])

# The two-node trap, from both directions: the naive rule gives you two
# leaders, and quorum gives you none. A pair bought for availability is
# either corrupt or fully read-only, and there is no third option.
simulate(2, [1, 1])

# Adding a node to an odd cluster buys nothing. 3 and 4 both tolerate exactly
# one failure, so the fourth node adds cost and coordination, not resilience.
for n in range(2, 8):
    print(f"n={n}  quorum={quorum(n)}  tolerates {n - quorum(n)} node failure(s)")
Expected Output:
$ python quorum.py
cluster of 3, partitioned into groups of 2 and 1   (quorum = 2)
  no quorum  2->leader, 1->leader               leaders: 2   <-- SPLIT BRAIN
  quorum     2->leader, 1->read-only            leaders: 1

cluster of 5, partitioned into groups of 3 and 2   (quorum = 3)
  no quorum  3->leader, 2->leader               leaders: 2   <-- SPLIT BRAIN
  quorum     3->leader, 2->read-only            leaders: 1

cluster of 2, partitioned into groups of 1 and 1   (quorum = 2)
  no quorum  1->leader, 1->leader               leaders: 2   <-- SPLIT BRAIN
  quorum     1->read-only, 1->read-only         leaders: 0

n=2  quorum=2  tolerates 0 node failure(s)
n=3  quorum=2  tolerates 1 node failure(s)
n=4  quorum=3  tolerates 1 node failure(s)
n=5  quorum=3  tolerates 2 node failure(s)
n=6  quorum=4  tolerates 2 node failure(s)
n=7  quorum=4  tolerates 3 node failure(s)

Each scenario runs the same partition through both rules, so the only thing that differs between the two lines is the promotion rule itself. Under the naive one every isolated group promotes and you get two leaders; under quorum exactly one side can. Note that the naive rule is not stupidity, it is the obvious reading of the evidence available to a node: it cannot reach the leader, so as far as it can tell the leader is gone. What quorum adds is not better information, it is a promise that two groups can never both act on the same conclusion.

The two-node case is worth reading twice, because it fails in both directions: the naive rule gives you two leaders and a corrupt cluster, and quorum gives you zero leaders and a fully read-only one. There is no third option, which is why a pair is the one cluster size never worth deploying for availability. The table underneath makes the companion point: a four-node cluster tolerates exactly one failure, the same as three, so the fourth machine buys coordination cost and no resilience. That is the whole reason clusters come in threes, fives and sevens.

Fencing: Force the Old Primary Offline

Quorum is necessary but not sufficient. It prevents a minority from electing a leader; it does nothing about a node that already believes it is the leader and is still reachable by some clients. Closing that gap is what fencing, or STONITH, is for: make certain the old primary is off before the new one comes up.

import shlex
import subprocess

# STONITH: Shoot The Other Node In The Head. Before promoting a replica, the
# cluster makes sure the old primary cannot possibly still be serving writes.
# Quorum stops a minority from ELECTING a leader; it does not stop a node that
# already believes it is leader from accepting writes from clients that can
# still reach it. Fencing is what closes that gap.

DRY_RUN = True            # flip to False only against hardware you intend to kill
UNREACHABLE = {"192.168.1.20"}   # stands in for a controller that does not answer


def run(argv, node_ip):
    """Run a fencing command, or print it when DRY_RUN is set."""
    if DRY_RUN:
        failed = node_ip in UNREACHABLE
        print(f"  would run: {shlex.join(argv)}"
              f"{'   -> no response' if failed else ''}")
        return 1 if failed else 0
    return subprocess.run(argv, check=False).returncode


def fence_node_power(node_ip, user="admin", password="password"):
    """Power fencing: cut power via the node's out-of-band controller (IPMI,
    iLO, DRAC). The strongest option, because a powered-off machine cannot
    write to shared storage no matter what it believes about itself."""
    return run(["ipmitool", "-H", node_ip, "-U", user, "-P", password,
                "power", "off"], node_ip)


def fence_node_network(node_ip, port=5432):
    """Network fencing: drop the node's database traffic at the switch or
    firewall. Weaker than power fencing, because the node keeps running and
    can still reach anything you forgot to block, such as shared storage."""
    return run(["iptables", "-A", "INPUT", "-s", node_ip, "-p", "tcp",
                "--dport", str(port), "-j", "DROP"], node_ip)


def promote_with_fencing(old_primary_ip, new_primary_ip):
    print(f"failover: {old_primary_ip} -> {new_primary_ip}")
    print(" 1. fence the old primary BEFORE promoting anything")
    if fence_node_power(old_primary_ip) != 0:
        # Never promote on a failed fence. An unfenced old primary plus a new
        # one is the split-brain you were trying to avoid.
        raise RuntimeError("fencing failed; refusing to promote")
    print(" 2. fence confirmed, safe to promote")
    print(f" 3. promote {new_primary_ip} and repoint the remaining replicas")


promote_with_fencing("192.168.1.10", "192.168.1.11")

# The case that actually decides whether your cluster is safe: the fence does
# not succeed. Staying down is the correct outcome, and it has to be the
# default, because the alternative is two primaries taking writes.
print()
try:
    promote_with_fencing("192.168.1.20", "192.168.1.21")
except RuntimeError as exc:
    print(f" !! {exc}")
    print("    The cluster stays read-only until an operator intervenes.")
    print("    Promoting here would produce exactly the split-brain that")
    print("    fencing exists to prevent.")

print()
print("network fencing, as a fallback when there is no out-of-band controller:")
fence_node_network("192.168.1.10")
Expected Output:
$ python fencing.py
failover: 192.168.1.10 -> 192.168.1.11
 1. fence the old primary BEFORE promoting anything
  would run: ipmitool -H 192.168.1.10 -U admin -P password power off
 2. fence confirmed, safe to promote
 3. promote 192.168.1.11 and repoint the remaining replicas

failover: 192.168.1.20 -> 192.168.1.21
 1. fence the old primary BEFORE promoting anything
  would run: ipmitool -H 192.168.1.20 -U admin -P password power off   -> no response
 !! fencing failed; refusing to promote
    The cluster stays read-only until an operator intervenes.
    Promoting here would produce exactly the split-brain that
    fencing exists to prevent.

network fencing, as a fallback when there is no out-of-band controller:
  would run: iptables -A INPUT -s 192.168.1.10 -p tcp --dport 5432 -j DROP

Two rules matter more than the choice of mechanism. Fence before promoting, not after, since the window between the two is exactly when both nodes are live. And never promote when fencing fails, which is the second failover in the output above: the controller does not answer, the code refuses to promote, and the cluster stays read-only until a human intervenes.

That second case looks like the system failing, and it is worth being clear that it is the system working. The alternative to staying down is promoting a replica while a node you could not kill may still be taking writes, which is the split-brain from earlier in this section, arrived at deliberately. An outage you can explain beats a divergence you have to reconcile by hand. Power fencing is stronger than network fencing for the same reason: a powered-off machine cannot reach shared storage either, whatever it still believes about itself. The example runs with DRY_RUN set, so it prints the commands rather than executing them.

Connection Pooling

Database connections are expensive (TCP handshake, authentication, memory allocation). Connection pools reuse connections across requests, reducing latency and database load.

Problem: Creating Connections Per Request
Without Pooling
def handle_request():
    conn = psycopg.connect(DSN)
    cur = conn.cursor()
    cur.execute("SELECT ...")
    results = cur.fetchall()
    conn.close()  # closed immediately
    return results

# Every request pays for a TCP
# handshake, SCRAM authentication,
# a forked backend process, and
# a teardown.

# Measured below: 7.00 ms per
# query on localhost, where the
# query itself is well under 1 ms.
# Over a real network with TLS,
# the gap is far wider.
With Pooling
from psycopg_pool import ConnectionPool

pool = ConnectionPool(
    "postgresql://demo@localhost/demo",
    min_size=5, max_size=50, open=True,
)

with pool.connection() as conn:
    ...

# The connection already exists, so
# only the query is paid for.

# Measured below: 0.54 ms per
# query, about 13x faster.
Application-Level Pooling

Start inside the application. psycopg 3 ships a pool whose context manager checks a connection out and always returns it, so there is no reason to hand-roll a getconn/putconn wrapper, and no path on which a connection leaks.

import time

import psycopg
from psycopg_pool import ConnectionPool

DSN = "postgresql://demo:demo@localhost:5432/demo"

# Create the pool once, at application startup. psycopg 3's pool already gives
# you a context manager that checks a connection out and always returns it, so
# no hand-rolled getconn/putconn wrapper is needed and no connection leaks on
# an exception path.
pool = ConnectionPool(DSN, min_size=5, max_size=50, open=True)
pool.wait(timeout=10)   # block until min_size connections are actually up

with pool.connection() as conn:
    conn.execute("DROP TABLE IF EXISTS users")
    conn.execute("CREATE TABLE users (id serial PRIMARY KEY, username text)")
    conn.execute("INSERT INTO users (username) VALUES ('alice')")


def get_user(user_id):
    # pool.connection() also wraps the block in a transaction: it commits on
    # clean exit and rolls back if the body raises.
    with pool.connection() as conn:
        return conn.execute(
            "SELECT id, username FROM users WHERE id = %s", (user_id,)
        ).fetchone()
    # the connection is back in the pool here, however the block exited


def timed(label, fn, n=200):
    start = time.perf_counter()
    for _ in range(n):
        fn()
    elapsed = (time.perf_counter() - start) / n * 1000
    print(f"{label:28} {elapsed:6.2f} ms per query")


def without_pool():
    with psycopg.connect(DSN) as conn:
        conn.execute("SELECT id, username FROM users WHERE id = 1").fetchone()


timed("new connection per query", without_pool)
timed("pooled connection", lambda: get_user(1))

# An exception inside the block still returns the connection to the pool.
try:
    with pool.connection() as conn:
        conn.execute("SELECT 1 / 0")
except psycopg.errors.DivisionByZero:
    print("query raised, connection returned anyway")
print("pool stats:", {k: v for k, v in pool.get_stats().items()
                      if k in ("pool_size", "pool_available", "requests_waiting")})

pool.close()   # at shutdown
Expected Output:
$ python pool_demo.py
new connection per query       7.08 ms per query
pooled connection              0.52 ms per query
query raised, connection returned anyway
pool stats: {'pool_size': 5, 'pool_available': 5, 'requests_waiting': 0}

Roughly 13x, for a query that returns one row over loopback. Almost all of the 7 ms is connection setup, so the saving grows with network latency and disappears entirely for long-running analytical queries. Note the last two lines: a query that raised inside the block still returned its connection, and the pool is back to five available.

Production-Grade Pooling with PgBouncer

An in-process pool helps one process. It cannot help twenty application containers that each open their own pool, because the database sees the sum of all of them. PgBouncer sits between the application and PostgreSQL as a separate process, so the limit is enforced once, for everyone.

# pgbouncer.ini
[databases]
demo = host=pg-primary port=5432 dbname=demo

[pgbouncer]
listen_addr = *
listen_port = 6432
auth_type = scram-sha-256      # md5 still works but is deprecated upstream
auth_file = /etc/pgbouncer/userlist.txt

pool_mode = transaction        # server connection is held only for the
                               # duration of a transaction, not the whole
                               # client session. This is what makes the
                               # multiplexing below possible.
max_client_conn = 1000         # how many clients may connect to PgBouncer
default_pool_size = 25         # server connections PER user+database pair
reserve_pool_size = 5          # extra slots, released under sustained load

The setting that matters most is pool_mode = transaction. A server connection is assigned only for the duration of a transaction rather than for a whole client session, and that is what allows a small number of backends to serve a large number of clients. It also comes with a cost: anything that lives on a session rather than a transaction stops working, including SET outside a transaction, session-level advisory locks, LISTEN/NOTIFY and server-side prepared statements. If you need those, use session mode and accept far less multiplexing.

To run it against the pair from the top of this lesson. The script writes both config files itself rather than assuming you saved the block above, for a reason worth knowing about Docker generally: a bind mount whose source path does not exist is not an error. Docker creates a directory at that path and mounts it, so the container starts and then fails from the inside with can't create /etc/pgbouncer/pgbouncer.ini: Is a directory, which points at the container rather than at the missing file on your machine.

# pgbouncer_up.sh - writes both config files, then starts PgBouncer.
#
# Write the files rather than assuming they exist. Docker does not fail when
# a bind-mount source is missing: it silently creates a DIRECTORY at that
# path, and PgBouncer then dies with the baffling
#   can't create /etc/pgbouncer/pgbouncer.ini: Is a directory
# If you have already hit that, delete the stray directories first:
#   docker rm -f pgbouncer; rmdir pgbouncer.ini userlist.txt

cat > pgbouncer.ini <<'INI'
[databases]
demo = host=pg-primary port=5432 dbname=demo

[pgbouncer]
listen_addr = *
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt

pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
reserve_pool_size = 5
INI

# userlist.txt holds the SCRAM verifier, which you copy from the server
# rather than write by hand. Postgres keeps it in pg_shadow. The verifier is
# salted, so regenerate this file whenever you recreate the role or the
# container: a stale one fails with "password authentication failed".
docker exec pg-primary psql -U demo -d demo -tAc \
  "SELECT '\"'||usename||'\" \"'||passwd||'\"' FROM pg_shadow WHERE usename='demo'" \
  > userlist.txt

docker rm -f pgbouncer > /dev/null 2>&1
docker run -d --name pgbouncer --network pgrepl -p 6432:6432 \
  -v "$PWD/pgbouncer.ini:/etc/pgbouncer/pgbouncer.ini:ro" \
  -v "$PWD/userlist.txt:/etc/pgbouncer/userlist.txt:ro" \
  edoburu/pgbouncer:latest > /dev/null

sleep 3
# Timestamps stripped so the output is the same on every run.
docker logs pgbouncer 2>&1 | sed -E 's/^[0-9-]{10} [0-9:.]+ UTC //' | tail -5

# Clean up:  docker rm -f pgbouncer
Expected Output:
[1] LOG kernel file descriptor limit: 1048576 (hard: 1048576); max_client_conn: 1000, max expected fd use: 1062
[1] LOG listening on 0.0.0.0:6432
[1] LOG listening on [::]:6432
[1] LOG listening on unix:/tmp/.s.PGSQL.6432
[1] LOG process up: PgBouncer 1.25.2, libevent 2.1.12-stable (epoll), adns: evdns2, tls: OpenSSL 3.5.6 7 Apr 2026

With that running, the multiplexing is easy to measure rather than take on faith. Counting rows in pg_stat_activity shows how many real backends PostgreSQL is holding while the clients think they each have a connection.

import threading
import time

import psycopg

# Straight to Postgres, used only to observe how many backends exist.
DIRECT = "postgresql://demo:demo@localhost:5432/demo"
# Through PgBouncer, which is what the application would actually use.
VIA_PGBOUNCER = "postgresql://demo:demo@localhost:6432/demo"


def server_backends():
    """How many real Postgres backends are serving this database right now."""
    with psycopg.connect(DIRECT) as conn:
        return conn.execute("""
            SELECT count(*) FROM pg_stat_activity
            WHERE datname = 'demo' AND backend_type = 'client backend'
        """).fetchone()[0] - 1        # minus this observing connection


# Run this against a freshly started PgBouncer. It keeps server connections
# warm after a burst (see the last line), so a second run starts at whatever
# the first run grew the pool to rather than at zero.
clients = [psycopg.connect(VIA_PGBOUNCER) for _ in range(100)]
print(f"client connections held open  : {len(clients)}")
print(f"postgres backends, all idle   : {server_backends()}")

# pool_mode = transaction means a server connection is only assigned for the
# duration of a transaction. Run the queries one after another and a single
# backend serves all 100 clients.
for conn in clients:
    conn.execute("SELECT 1").fetchone()
    conn.commit()
print(f"after 100 serial transactions : {server_backends()}")

# Concurrency is what actually drives the backend count: PgBouncer needs one
# server connection per IN-FLIGHT transaction, and default_pool_size is the
# ceiling. Ask for 40 at once and 25 run while 15 queue for a free slot.
peak = 0


def busy_transaction(conn):
    conn.execute("SELECT pg_sleep(0.4)")
    conn.commit()


threads = [threading.Thread(target=busy_transaction, args=(c,))
           for c in clients[:40]]
for t in threads:
    t.start()
for _ in range(12):
    peak = max(peak, server_backends())
    time.sleep(0.05)
for t in threads:
    t.join()

print(f"peak backends, 40 concurrent  : {peak}  (default_pool_size = 25)")
# Not back to 1: PgBouncer holds grown server connections until
# server_idle_timeout (600s by default) rather than reconnecting per burst.
print(f"after the burst, still warm   : {server_backends()}")

for conn in clients:
    conn.close()
Expected Output:
$ python pgbouncer_demo.py
client connections held open  : 100
postgres backends, all idle   : 1
after 100 serial transactions : 1
peak backends, 40 concurrent  : 25  (default_pool_size = 25)
after the burst, still warm   : 25

A hundred client connections cost exactly one backend, because the transactions ran one after another and a backend is only needed while a transaction is open. The number that drives backend count is concurrency, not connection count: ask for forty simultaneous transactions and it rises to twenty-five and stops there, which is default_pool_size doing its job while the remaining fifteen wait for a slot. So the familiar "1000 connections becomes 25" claim is really a statement about concurrent transactions, and it reduces the memory and context-switching cost of idle backends, not the amount of query work the database performs.

High Availability Best Practices

Most of these follow directly from something demonstrated above: the quorum arithmetic, the paused replica, the blocked synchronous commit, the backend count under PgBouncer. The one that is easiest to skip and most expensive to skip is testing failover, because an untested failover path is not a high-availability setup, it is an assumption.

Do This
  • Use odd number of nodes (3, 5, 7) for quorum-based systems
  • Monitor replication lag continuously (alert if >5 seconds)
  • Test failover regularly (monthly chaos engineering)
  • Use connection pooling (PgBouncer, HikariCP) to reduce load
  • Implement read-your-writes consistency for user-facing queries
  • Enable automated fencing to prevent split-brain
  • Distribute replicas geographically for disaster recovery
  • Set up alerts for: replication lag, failed nodes, split-brain
Avoid This
  • Don't use 2-node clusters (can't achieve quorum, both go read-only)
  • Don't ignore replication lag (causes stale reads and failover issues)
  • Don't rely on DNS for failover alone (TTL causes delays)
  • Don't skip connection pooling (database overwhelmed by connections)
  • Don't assume replicas are real-time (async = eventual consistency)
  • Don't use multi-primary unless truly needed (complexity not worth it)
  • Don't test failover only in staging (production behaves differently)
  • Don't create new connections per request (use pooling!)

Replication Architecture Decision Tree

Work through these in order and stop at the first answer that fits. The questions are deliberately ordered cheapest-first: read replicas before multi-primary, asynchronous before synchronous, because each step down the list costs real operational complexity that you should only pay for once a requirement forces it.

1. What is your read/write ratio?
  • 90%+ reads → Use primary-replica with read replicas
  • Balanced or write-heavy → Continue to question 2
2. Do you need geo-distributed writes?
  • YES → Use multi-primary replication (accept complexity)
  • NO → Continue to question 3
3. What is your uptime requirement?
  • 99.9% (8.7hr downtime/year)Manual failover acceptable
  • 99.99% (52min downtime/year)Automated failover (Patroni, AWS RDS Multi-AZ)
  • 99.999% (5min downtime/year)Multi-region active-passive with instant failover
4. Can you tolerate eventual consistency for reads?
  • YES → Use asynchronous replication (fast writes)
  • NO → Use synchronous replication (slow writes, zero data loss)
Common Pattern: For most web applications, the optimal architecture is: 1 primary + 2-3 replicas with asynchronous replication and automated failover (Patroni).This achieves 99.95%+ uptime with manageable complexity.

Key Takeaways

  • Replication scales reads, not writes - every replica applies every write, so more replicas never add write capacity. A write bottleneck needs sharding instead.
  • Prove routing with pg_is_in_recovery() - it is false on a primary and true on a standby, and a write sent to a replica fails loudly with ReadOnlySqlTransaction rather than going somewhere unexpected.
  • Read-your-writes is a routing rule, not a query rule - the author reads from the primary, everyone else reads from replicas. Reproduce the bug deterministically with pg_wal_replay_pause() instead of hoping to lose the race in testing.
  • Alert on replay_lag, not on pg_last_xact_replay_timestamp() - the latter keeps climbing whenever the primary is idle, so it reports 18 seconds of "lag" on a replica that is fully caught up.
  • One synchronous standby lowers availability - lose it and commits block forever with no error. Use the quorum form, ANY 1 (replica1, replica2).
  • Quorum is arithmetic - a strict majority cannot be held by two disjoint groups. Two-node clusters go entirely read-only when split, and four nodes tolerate no more failures than three.
  • Quorum does not replace fencing - it stops a minority electing a leader, not a node that already thinks it is one. Fence before promoting, and refuse to promote if fencing fails.
  • Pooling multiplexes concurrency, not connections - 100 idle clients cost 1 backend, while 40 concurrent transactions cost 25. The saving is per-connection memory and context switching, not query work.
  • Multi-primary trades errors for divergence - conflicting writes both commit successfully and the nodes disagree permanently, so a conflict resolution policy is mandatory rather than optional.
What's Next?
  • Caching strategies - replicas reduce read load by spreading it; caching removes it. The next lesson covers Redis, cache-aside and write-through, and the invalidation problems that follow.
  • The same staleness question, one layer up - a cache is another asynchronous replica of your data, so read-your-writes returns as a cache invalidation problem.
  • Monitoring and observability - Lesson 28 turns the lag and pool metrics used here into dashboards and alerts.