PostgreSQL in Depth Module 10 – WAL, replication and high availability
Streaming replication: standbys, slots and lag
Build a PostgreSQL 18 hot standby with pg_basebackup, stop its replication slot from filling the disk, and measure replication lag in bytes and in time.
What you will learn
- Build a hot standby with pg_basebackup -R -C -S and confirm from both servers that it streams
- Limit the WAL a replication slot may keep, and recover a standby whose slot was lost
- Measure replication lag at each of the four positions and convert it into seconds
- Choose the standbys a commit waits for and the synchronous_commit level of a write
- Decide between max_standby_streaming_delay and hot_standby_feedback for a standby's workload
Before you start
What you’ll learn · Before you start
What you will learn
- Build a hot standby with pg_basebackup -R -C -S and confirm from both servers that it streams
- Limit the WAL a replication slot may keep, and recover a standby whose slot was lost
- Measure replication lag at each of the four positions and convert it into seconds
- Choose the standbys a commit waits for and the synchronous_commit level of a write
- Decide between max_standby_streaming_delay and hot_standby_feedback for a standby's workload
Before you start
On this page
A primary that commits 300 transactions a second loses its disk, and every commit that no other server has received is gone with it. A hot standby: A standby server that answers read-only queries while it replays the primary's write-ahead log. The setting hot_standby is on by default, so a standby built with pg_basebackup is a hot standby. is that other server: a copy of the whole cluster that follows the primary within moments and answers read-only queries while it does.
The primary keeps it current by streaming replication: Replication in which a standby keeps a connection open to the primary and receives its write-ahead log records as they are written, instead of waiting for each WAL segment file to be filled and shipped.: it sends each record of its write-ahead log: The ordered record of every change to a PostgreSQL cluster, flushed to disk before the changed data pages may be written. Crash recovery, archiving and replication all replay it. More in “WAL, checkpoints, checksums and durability” as it writes it, and the standby replays the records. You will build one on PostgreSQL 18.6 with a single command and read it from both servers in a recorded run.
One command builds a standby that streams
The run starts a primary on port 5432 with 100 orders. One command then copies it, running, into the folder standby
and prepares the copy to follow it: pg_basebackup -D standby -R -X stream -c fast -C -S standby1. The copy is a
base backup: A copy of a running cluster's files, made with pg_basebackup, that is the starting point for recovery. It needs the WAL written during and after it to be consistent., and each option adds one piece:
-X streamstreams the WAL written during the copy alongside it, so the copy is consistent on its own.-c fastasks for an immediate checkpoint, so the copy starts at once.-C -S standby1creates the replication slot: A named record on a server of how far one consumer, a standby or a logical decoding client, has read. The server keeps the WAL that consumer still needs and, through the slot's xmin and catalog_xmin, the row versions it may still need, even while it is disconnected. More in “MVCC in production: the xmin horizon”standby1and uses it, so the primary keeps the WAL the standby needs between the end of the copy and its first connection.-Rwrites the filestandby.signaland the connection settings into postgresql.auto.conf: The configuration file in the data directory that ALTER SYSTEM writes. The server reads it after postgresql.conf, so a value set there overrides the same setting in postgresql.conf. More in “Creating a cluster: initdb, settings, ALTER SYSTEM”.
The session starts the copy on port 5433, shows what makes it a standby, reads from it and tries a write there:
standby_session.py
"""A hot standby on PostgreSQL 18.6, built with one command: standby_setup.sql writes 100 orders on a primary, and
pg_basebackup -R -C -S copies the running primary into the folder standby, creates the replication slot standby1 for
it and writes the settings that make the copy start as a standby. The copy starts on a port of its own (shown as
5433). The run lists the file that puts it in standby mode, prints the settings -R added, reads from the standby,
tries a write there, and reads the primary's two replication views.
The standby gets cluster_name = 'standby1', which is the name it gives the primary. Its connection string names the
run's password file (shown as pgpass), never a password. standby_primary.sql shows how far replay is behind in whole
kB, because a background record of a few dozen bytes (one the server writes about running transactions) may be on
its way at the moment of reading. Every value in the record is as the servers printed it, with the ports shown as
5432 and 5433. initdb runs with --no-sync, which only skips flushing its new files to disk.
"""
from pg_session import Server
# The settings of this track's sessions: no background work that would make two runs differ.
SETTINGS = {"autovacuum": "off", "jit": "off", "timezone": "'UTC'", "log_line_prefix": "''",
"max_parallel_workers_per_gather": 0}
CAUGHT_UP = "SELECT pg_wal_lsn_diff(sent_lsn, replay_lsn) FROM pg_stat_replication"
with Server(settings=SETTINGS, initdb=("--no-sync",)) as pg:
pg.up()
pg.psql("standby_setup.sql", note="on the primary: 100 orders")
port = pg.standby(slot="standby1", show=True, settings={"cluster_name": "'standby1'"})
pg.listing("standby", prefix="standby")
pg.show_file("standby/postgresql.auto.conf", note="what -R added to the standby's settings")
pg.psql("standby_check.sql", port=port, note="on the standby")
pg.psql("standby_write.sql", port=port, errors=1, status=3, note="a write on the standby")
# Unprinted: wait until the standby has replayed everything the primary sent, as it does within moments.
pg.wait_for(CAUGHT_UP, "0")
pg.psql("standby_primary.sql", note="on the primary") Output
-- on the primary: 100 orders
$ psql -X -a -f standby_setup.sql
CREATE TABLE orders (id bigserial PRIMARY KEY, total numeric(12,2) NOT NULL);
CREATE TABLE
INSERT INTO orders (total) SELECT 100 + g FROM generate_series(1, 100) AS g;
INSERT 0 100
$ pg_basebackup -D standby -R -X stream -c fast -C -S standby1
-- in standby, the names that start with standby:
standby.signal
-- what -R added to the standby's settings
$ cat standby/postgresql.auto.conf
# Do not edit this file manually!
# It will be overwritten by the ALTER SYSTEM command.
primary_conninfo = 'user=postgres passfile=''pgpass'' channel_binding=prefer connect_timeout=10 host=127.0.0.1 port=5432 sslmode=prefer sslnegotiation=postgres sslcompression=0 sslcertmode=allow sslsni=1 ssl_min_protocol_version=TLSv1.2 gssencmode=disable krbsrvname=postgres gssdelegation=0 target_session_attrs=any load_balance_hosts=disable dbname=postgres'
primary_slot_name = 'standby1'
-- on the standby
$ psql -X -a -f standby_check.sql
SELECT pg_is_in_recovery();
pg_is_in_recovery
-------------------
t
(1 row)
SELECT count(*) AS orders, sum(total) AS total FROM orders;
orders | total
--------+----------
100 | 15050.00
(1 row)
-- a write on the standby
$ psql -X -a -f standby_write.sql
\set ON_ERROR_STOP on
INSERT INTO orders (total) VALUES (1);
psql:standby_write.sql:2: ERROR: cannot execute INSERT in a read-only transaction
-- on the primary
$ psql -X -a -f standby_primary.sql
SELECT application_name, state, sync_state,
round(pg_wal_lsn_diff(sent_lsn, replay_lsn) / 1024) AS replay_behind_kb
FROM pg_stat_replication;
application_name | state | sync_state | replay_behind_kb
------------------+-----------+------------+------------------
standby1 | streaming | async | 0
(1 row)
SELECT slot_name, slot_type, active, wal_status
FROM pg_replication_slots;
slot_name | slot_type | active | wal_status
-----------+-----------+--------+------------
standby1 | physical | t | reserved
(1 row)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 standby_session.py
standby_setup.sql
CREATE TABLE orders (id bigserial PRIMARY KEY, total numeric(12,2) NOT NULL);
INSERT INTO orders (total) SELECT 100 + g FROM generate_series(1, 100) AS g; standby_check.sql
SELECT pg_is_in_recovery();
SELECT count(*) AS orders, sum(total) AS total FROM orders; standby_write.sql
\set ON_ERROR_STOP on
INSERT INTO orders (total) VALUES (1); standby_primary.sql
SELECT application_name, state, sync_state,
round(pg_wal_lsn_diff(sent_lsn, replay_lsn) / 1024) AS replay_behind_kb
FROM pg_stat_replication;
SELECT slot_name, slot_type, active, wal_status
FROM pg_replication_slots; pg_session.py
"""Throwaway PostgreSQL 18.6 clusters for the examples of this lesson, including standbys and a second cluster.
Every session creates its clusters with initdb in new folders inside the examples folder, starts them, runs psql
scripts and server programs on them, prints what they printed, and stops and deletes them again, also when something
fails. The servers are the pinned PostgreSQL 18.6 from the mise installations next to the Python that runs this file.
Each listens only on 127.0.0.1, an address no other computer can reach, on a port the operating system reports as
unused, with no Unix socket, and asks for a password (scram-sha-256) made up for the run and never printed. A server
that another server must reach (a standby, a subscriber) is given a password file of its own, mode 0600, so no
connection string in the output carries a password.
learn:verify's run sandbox cannot read the home folder, where mise keeps PostgreSQL, so these sessions are recorded
with LEARN_VERIFY_NETWORK=allow; they read only this folder and the toolchain and connect to nothing but 127.0.0.1.
The output is exactly what the programs printed, with five substitutions so that a record never shows the computer it
ran on and is the same on every run: each run folder is shown as ".", the folder of PostgreSQL's programs is left out
of their paths, the operating-system user's name is shown as "learner", the dots pg_ctl prints while it waits (one
about every tenth of a second) as three, and the port the operating system picked for each cluster as a fixed number
(5432 for the first cluster, 5433 for its standby, 5434 for a second cluster), the numbers a reader would see with
PostgreSQL's own defaults. A session may name more patterns (mask=) for values that change on every run, such as a
log sequence number, a timing or a process id; the session's own file says which, and its record shows fixed text in
their place. A session stops with status 1 when a command fails that should have worked, or reports a different
number of errors than the session expects, so a run that went wrong is never recorded as if it had worked.
"""
import os
import pwd
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
from pathlib import Path
PG_VERSION = "18.6"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
BIN = INSTALLS / "postgres" / PG_VERSION / "bin"
OS_USER = pwd.getpwuid(os.geteuid()).pw_name
# Every cluster of the run, so that each one's output can be cleaned of every run folder and port.
BASES = []
PORTS = {}
# The passwords of this run: no output of a session ever shows one, whatever a program decides to write.
PASSWORDS = []
def free_port():
"""A TCP port on 127.0.0.1 that nothing listens on now."""
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
class Server:
"""One throwaway cluster: initdb, settings, start; stopped and deleted when the with block ends."""
def __init__(self, settings=None, initdb=(), name="data", shown_port=5432):
self.settings = dict(settings or {})
self.initdb_args = list(initdb)
self.name = name
self.shown_port = shown_port
self.running = []
def __enter__(self):
if not (BIN / "postgres").exists():
sys.exit(f"PostgreSQL {PG_VERSION} was not found next to this Python")
self.base = Path(tempfile.mkdtemp(prefix="pg", dir=".")).resolve()
self.port = free_port()
self.password = secrets.token_urlsafe(24)
(self.base / "pwfile").write_text(self.password + "\n")
BASES.append(self.base)
PORTS[self.port] = self.shown_port
PASSWORDS.append(self.password)
self.env = {**os.environ, "PGHOST": "127.0.0.1", "PGPORT": str(self.port), "PGUSER": "postgres",
"PGPASSWORD": self.password, "PGDATABASE": "postgres", "PGCONNECT_TIMEOUT": "10"}
for key in ("PGSERVICE", "PGSERVICEFILE", "PGOPTIONS", "PSQLRC"):
self.env.pop(key, None)
# The server itself must not inherit this cluster's password: a replication connection to ANOTHER cluster
# would use it instead of the password file its connection string names.
self.server_env = {k: v for k, v in self.env.items() if k != "PGPASSWORD"}
for script in Path(".").glob("*.sql"):
shutil.copy(script, self.base / script.name)
for data in Path(".").glob("*.csv"):
shutil.copy(data, self.base / data.name)
return self
def __exit__(self, *exc):
for folder, _port in reversed(self.running):
self._call(["pg_ctl", "-D", folder, "-m", "fast", "-w", "-t", "60", "stop"])
self.running = []
shutil.rmtree(self.base, ignore_errors=True)
# ---- the cluster ---------------------------------------------------------------------------------------------
def initdb(self, show=False):
args = ["initdb", "-D", self.name, "-U", "postgres", "--auth=scram-sha-256", "--pwfile=pwfile",
*self.initdb_args]
if not show:
args.append("--no-instructions")
self._expect(args, show=show)
with open(self.base / self.name / "postgresql.conf", "a") as conf:
conf.write("\n# Added by the session: 127.0.0.1 only, no Unix socket.\n")
conf.write(f"listen_addresses = '127.0.0.1'\nport = {self.port}\nunix_socket_directories = ''\n")
for key, value in self.settings.items():
conf.write(f"{key} = {value}\n")
def start(self, show=False, folder=None, port=None, note=None):
folder = folder or self.name
if note:
print(f"-- {note}")
code, out = self._call(["pg_ctl", "-D", folder, "-l", f"{folder}.log", "-w", "-t", "60", "start"],
env=self.server_env)
if show:
_show(["pg_ctl", "-D", folder, "-l", f"{folder}.log", "-w", "-t", "60", "start"], out)
if code != 0:
if not show:
print(out)
log = self.base / f"{folder}.log"
if log.exists():
print(self.clean("\n".join(log.read_text().splitlines()[-25:])))
sys.exit(f"pg_ctl start of {folder} ended with status {code}")
self.running.append((folder, port or self.port))
def stop(self, folder=None, show=False, mode="fast"):
"""Stops one cluster of this session (the standby, or the primary of a failover drill)."""
folder = folder or self.name
self._expect(["pg_ctl", "-D", folder, "-m", mode, "-w", "-t", "60", "stop"], show=show)
self.running = [r for r in self.running if r[0] != folder]
def up(self):
"""initdb and start, quietly."""
self.initdb()
self.start()
return self
def passfile(self):
"""A libpq password file for this cluster, so a connection string never carries the password."""
path = self.base / "pgpass"
if not path.exists():
path.write_text(f"127.0.0.1:*:*:postgres:{self.password}\n")
path.chmod(0o600)
return path
def conninfo(self, db="postgres", user="postgres", application_name=None, port=None, hosts=None):
"""A connection string another server can use to reach this one (password in a file, never in the text).
port names another cluster of this run (a promoted standby); hosts is a list of ports for a multi-host
connection string (host=127.0.0.1,127.0.0.1 port=a,b).
"""
if hosts:
where = f"host={','.join('127.0.0.1' for _ in hosts)} port={','.join(str(p) for p in hosts)}"
else:
where = f"host=127.0.0.1 port={port or self.port}"
parts = [f"{where} user={user} dbname={db} passfile={self.passfile()}"]
if application_name:
parts.append(f"application_name={application_name}")
return " ".join(parts)
def standby(self, folder="standby", slot=None, shown_port=5433, wait=True, extra=(), show=False, settings=None):
"""A streaming standby of this cluster, made with pg_basebackup -R, started on a port of its own.
slot names a physical replication slot pg_basebackup creates and the standby then uses (-C -S). extra adds
arguments to pg_basebackup; settings are lines appended to the standby's postgresql.conf. Returns its port.
"""
args = ["pg_basebackup", "-D", folder, "-R", "-X", "stream", "-c", "fast"]
if slot:
args += ["-C", "-S", slot]
args += list(extra)
# PGPASSFILE (and no PGPASSWORD) so that the primary_conninfo pg_basebackup -R writes names the password
# file: the standby then authenticates without a password in any file the record shows.
self._expect(args, show=show, env={**self.server_env, "PGPASSFILE": str(self.passfile())})
port = free_port()
PORTS[port] = shown_port
with open(self.base / folder / "postgresql.conf", "a") as conf:
conf.write(f"port = {port}\n")
for key, value in dict(settings or {}).items():
conf.write(f"{key} = {value}\n")
self.start(folder=folder, port=port)
if wait:
for _ in range(300):
if self.query("SELECT count(*) FROM pg_stat_replication WHERE state = 'streaming'") != "0":
return port
time.sleep(0.1)
sys.exit("the standby did not start streaming")
return port
def promote(self, folder="standby", show=True, port=None):
"""Promotes a standby to a primary with pg_ctl promote and waits until it accepts writes."""
self._expect(["pg_ctl", "-D", folder, "-w", "-t", "60", "promote"], show=show)
port = port or dict((f, p) for f, p in self.running).get(folder)
for _ in range(300):
if self.query("SELECT pg_is_in_recovery()", port=port) == "f":
return port
time.sleep(0.1)
sys.exit(f"{folder} did not leave recovery")
def rewind(self, folder, source=None, port=None, show=True, extra=(), target_port=None):
"""pg_rewind: resynchronises the stopped cluster in `folder` with a running cluster (a Server or a port).
It runs without this cluster's password in the environment and with PGPASSFILE set, so a -R run writes a
primary_conninfo that names the password file instead of a password. pg_rewind copies the source cluster's
postgresql.conf, which on one machine carries the source's own port, so the target's port is written back
afterwards (one machine runs both clusters here; on two machines both would listen on 5432).
"""
who = source or self
args = ["pg_rewind", "--target-pgdata", folder, f"--source-server={who.conninfo(port=port)}", *extra]
out = self._expect(args, show=show, env={**self.server_env, "PGPASSFILE": str(who.passfile())})
mine = target_port or dict((f, p) for f, p in self.running).get(folder) or self.port
with open(self.base / folder / "postgresql.conf", "a") as conf:
conf.write(f"port = {mine}\n")
return out
# ---- running things ------------------------------------------------------------------------------------------
def _call(self, args, env=None):
args = [str(a) for a in args]
done = subprocess.run([str(BIN / args[0]), *args[1:]], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, cwd=self.base, env=env or self.env)
return done.returncode, self.clean(done.stdout)
def _expect(self, args, show=False, status=0, mask=(), env=None):
code, out = self._call(args, env=env)
out = _masked(out, mask)
if show:
_show(args, out)
if code != status:
if not show:
print(out)
sys.exit(f"{args[0]} ended with status {code}, not {status}")
return out
def clean(self, text):
"""Every run folder as ".", the user as learner, pg_ctl's dots as three, each port as a fixed number.
A password the run made up is never shown, even when a program (pg_rewind -R, for one) writes it into a file
the session prints.
"""
text = text.replace(str(BIN) + "/", "")
for base in BASES:
text = text.replace(str(base) + "/", "").replace(str(base), ".")
for real, shown in PORTS.items():
text = re.sub(rf"\b{real}\b", str(shown), text)
for password in PASSWORDS:
text = text.replace(password, "(the password of this run, not shown)")
text = re.sub(r"(waiting for server to (?:start|shut down|promote))\.+", r"\1...", text)
return text.replace(f'"{OS_USER}"', '"learner"')
def wait_for(self, sql, value, port=None):
"""Waits (unprinted) until a query returns the value, for example after a reload."""
for _ in range(600):
if self.query(sql, port=port) == value:
return
time.sleep(0.1)
sys.exit(f"{sql} never returned {value}")
def show_file(self, path, note=None):
"""Prints a text file of the cluster, as `cat` would."""
if note:
print(f"-- {note}")
print(f"$ cat {path}")
print(self.clean((self.base / path).read_text()).rstrip("\n"))
print()
def log_lines(self, path, match, count=1, note=None, mask=()):
"""Waits for and prints the server-log lines that contain `match` (the server log of one cluster)."""
log = self.base / path
for _ in range(600):
found = [ln for ln in log.read_text().splitlines() if match in ln] if log.exists() else []
if len(found) >= count:
break
time.sleep(0.1)
else:
sys.exit(f"{path} never showed {count} line(s) with {match!r}")
print(f"-- {note or f'{path}, the lines about {match}'}:")
for line in found[:count]:
print(_masked(self.clean(line), mask))
print()
return found[:count]
def query(self, sql, port=None):
"""One value from one query, unprinted (the session uses it to wait or to decide)."""
env = {**self.env, "PGPORT": str(port or self.port)}
code, out = self._call(["psql", "-X", "-At", "-c", sql], env=env)
if code != 0:
sys.exit(f"the query failed: {out}")
return out.strip()
def silent(self, sql, port=None):
"""Runs a statement without printing it (used only to give a role the run's password)."""
self.query(sql, port=port)
def psql(self, script, errors=0, status=None, user=None, db=None, port=None, flags=("-a",), note=None, mask=(),
variables=()):
"""Runs a psql script with -X (no ~/.psqlrc) and the given flags, and prints the command and its output.
`errors` is how many ERROR lines the script must print; `status` the exit status it must end with;
`variables` are (name, value) pairs the script reads as :'name' (a connection string, for example)."""
env = {**self.env, "PGPORT": str(port or self.port)}
if user:
env["PGUSER"] = user
if db:
env["PGDATABASE"] = db
passed = []
for name, value in variables:
passed += ["-v", f"{name}={value}"]
args = ["psql", "-X", *flags, *passed, "-f", script]
code, out = self._call(args, env=env)
out = _masked(out, mask)
shown = ["psql", "-X", *flags, "-f", script]
if user:
shown[1:1] = ["-U", user]
if db:
shown[1:1] = ["-d", db]
if note:
print(f"-- {note}")
_show(shown, out)
found = sum(1 for line in out.splitlines() if "ERROR:" in line or ": error:" in line)
expected_status = 0 if status is None else status
if found != errors or code != expected_status:
sys.exit(f"{script}: {found} errors and status {code}; the session expects {errors} and {expected_status}")
def sessions(self, count):
"""Opens `count` idle psql connections (each one a client backend) until the with block ends."""
return _Idle(self, count)
def shell(self, *args, status=0, mask=(), note=None, timeout=None, stdin=None):
"""Runs one of PostgreSQL's programs and prints the command and its output."""
if note:
print(f"-- {note}")
if timeout is None:
return self._expect(list(args), show=True, status=status, mask=mask)
return self._timed(list(args), status=status, mask=mask, timeout=timeout, stdin=stdin)
def _timed(self, args, status, mask, timeout, stdin=None):
"""Runs a program that would not stop by itself (pg_recvlogical) and prints what it printed until timeout."""
args = [str(a) for a in args]
proc = subprocess.Popen([str(BIN / args[0]), *args[1:]], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
stdin=subprocess.PIPE if stdin is not None else None, text=True, cwd=self.base,
env=self.env)
try:
out, _ = proc.communicate(input=stdin, timeout=timeout)
except subprocess.TimeoutExpired:
proc.terminate()
out, _ = proc.communicate()
out = _masked(self.clean(out), mask)
_show(args, out)
return out
def listing(self, folder, prefix=""):
"""Prints the names in a folder of the cluster (sorted, folders with a slash), as `ls -p` would list them."""
path = self.base / folder
names = sorted(n + ("/" if (path / n).is_dir() else "") for n in os.listdir(path) if n.startswith(prefix))
print(f"-- in {folder}" + (f", the names that start with {prefix}" if prefix else "") + ":")
print(textwrap.fill(" ".join(names), width=78, initial_indent=" ", subsequent_indent=" ",
break_on_hyphens=False))
print()
return names
def _masked(text, mask):
"""Replaces what differs on every run (an LSN, a timing, a process id) with fixed text (see the docstring)."""
for pattern, replacement in mask:
text = re.sub(pattern, replacement, text, flags=re.M)
return text
def _arg(a):
"""A command-line argument as a reader would type it: double quotes around text with single quotes in it."""
a = str(a)
if re.fullmatch(r"[\w@%+=:,./-]+", a):
return a
if not re.search(r"[\"$`\\!]", a):
return f'"{a}"'
return shlex.quote(a)
def _show(args, out):
print("$ " + " ".join(_arg(a) for a in args))
if out.strip():
print(out.rstrip("\n"))
print()
class _Idle:
"""Idle psql connections, closed again when the with block ends."""
def __init__(self, server, count):
self.server, self.count, self.procs = server, count, []
def __enter__(self):
before = int(self.server.query("SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'"))
for _ in range(self.count):
self.procs.append(subprocess.Popen([str(BIN / "psql"), "-X", "-q"], stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env=self.server.env, cwd=self.server.base))
sql = "SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'"
for _ in range(300):
# the query's own connection is one more client backend
if int(self.server.query(sql)) == before + self.count:
return self
time.sleep(0.1)
sys.exit("the idle connections did not open")
def __exit__(self, *exc):
for p in self.procs:
p.stdin.close()
p.wait(timeout=30) pg_basebackup printed nothing: without -P or -v it is silent when all goes well. In the copy,
postgresql.auto.conf gained
primary_conninfo, which names the primary at port 5432 and a password file instead of a password, and
primary_slot_name = 'standby1'. A server that finds standby.signal when it starts enters standby mode: it connects
with those settings, receives the WAL and replays it. So pg_is_in_recovery() returned t, all 100 orders, 15050.00
in total, were there, and the INSERT failed with cannot execute INSERT in a read-only transaction: a server in
recovery runs read-only transactions only. Step through one commit’s way from the primary to a query on the standby:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A client commits. The client inserts an order on the primary and commits. The primary flushes the transaction's WAL to its own disk and answers the client; it does not wait for the standby.
- The walsender streams it. A walsender process on the primary reads the new WAL and sends it over the standby's replication connection at once, without waiting for a 16 MB segment file to fill.
- The standby writes and flushes. The standby's walreceiver writes the records into its own pg_wal folder and flushes them to its disk. From now on the change is on the disks of two servers.
- The startup process replays. The standby's startup process applies the records to its own data files, as crash recovery would. Only now can a query on the standby see the new order.
- The standby reports back. The walreceiver tells the primary how far it has written, flushed and replayed. The primary shows those positions for each standby in pg_stat_replication.
- Reads go to the standby. A reporting query can now run on the standby and sees the order. A write sent there is refused, because a server in recovery runs read-only transactions only.
Building the standby without a slot, then starting it a day later
Without -S, nothing makes the primary keep the WAL written after the copy. A checkpoint may recycle it before the
standby connects, and then the standby needs a WAL archive or a new base backup.
Checkpoint
Check: Building a standby with one pg_basebackup
Both servers report how far the standby has got
On the primary, pg_stat_replication has one row for each walsender: A process on a PostgreSQL primary that streams write-ahead log to one standby or logical replication subscriber; the primary runs one for each. More in “PostgreSQL architecture: processes, memory, files”, so one for each
standby. In the run the standby called itself standby1, its cluster_name; state read streaming, sync_state
read async because no commit waits for it, and replay was 0 kB behind. The row carries four positions, each an
LSN: Log sequence number: a byte position in the WAL, written as two hexadecimal numbers around a slash, such as 0/3521448. The difference between two LSNs is the number of WAL bytes between them. More in “WAL, checkpoints, checksums and durability”:
| Position | The standby has |
|---|---|
sent_lsn |
been sent the WAL up to here |
write_lsn |
written it into its own pg_wal |
flush_lsn |
flushed it to its disk |
replay_lsn |
replayed it, so its queries see it |
The gap between two positions, in bytes from pg_wal_lsn_diff(), is the
replication lag: How far a standby is behind its primary: in bytes, as the gap between two WAL positions, or in time, from a commit on the primary until the standby has replayed it. at that step. The standby reports its positions whenever its write
or flush position moves, and at least every 10 seconds. On its own side, pg_last_wal_receive_lsn() and
pg_last_wal_replay_lsn() show what it has received and replayed.
Taking streaming for caught up
state reads streaming once the standby has caught up with the primary, and it stays so while new WAL is sent.
It says nothing about replay: read the gap to replay_lsn, as the run did.
The primary’s pg_replication_slots listed the slot standby1 as active, with wal_status reserved: the WAL it
keeps fits within the primary’s max_wal_size.
Key idea
A standby replays the primary’s WAL. On the primary, pg_stat_replication and pg_replication_slots say whether
it is connected, how far each step has got and what is kept for it.
A standby that runs for months raises more questions, which the parts after the practice answer with recorded runs:
- A replication slot makes the primary keep the WAL its standby has not received, even while the standby is stopped.
max_slot_wal_keep_sizecaps what a slot may keep, and PostgreSQL 18’sidle_replication_slot_timeoutretires slots nobody uses.pg_wal_replay_pause()stops replay on a standby, which makes lag on purpose so that each position can be read.synchronous_standby_namesnames the standbys a commit waits for, andsynchronous_commitsays how far they must get.max_standby_streaming_delaylimits how long replay waits for a standby query, andhot_standby_feedbackkeeps the rows such queries read.
Checkpoint
Check: Reading a standby from both servers
Practise: reading a standby from both servers
The questions use the run’s primary on port 5432 and its standby standby1. Decide what each server shows before you
open the answer.
Practice that adapts to you
Practise this lesson
6 questions about 8 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Keeping a replication slot from filling the primary's disk Pro: opens with a Pro, Premium or Ultimate pass
A slot keeps WAL for a standby that is away, and for one that never comes back it keeps WAL until the disk is full. The run below takes one slot from streaming to lost, and the limits after it stop a forgotten slot in time.
In this part:
- What a slot keeps, and what a cap costs
- Sizing the cap, and retiring idle slots
1 diagram · 1 runnable example (pg_session.py, slot_behind.sql, slot_cap.sql, slot_checkpoint.sql, slot_fill.sql, slot_session.py, slot_setup.sql, slot_state.sql) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Lag in bytes and seconds, and commits that wait for a standby Pro: opens with a Pro, Premium or Ultimate pass
Lag has a size, a duration and a place, and the four positions of pg_stat_replication show where it sits. A
synchronous standby turns lag into time added to each commit, and the trade-off table below weighs the five levels.
In this part:
- Lag made on purpose, read at four positions
- Commits that wait for a synchronous standby
1 runnable example (lag_after.sql, lag_pause.sql, lag_primary.sql, lag_resume.sql, lag_session.py, lag_setup.sql, lag_standby.sql, lag_write.sql) · about 9 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Standby queries that replay cancels, and the feedback that saves them Pro: opens with a Pro, Premium or Ultimate pass
A long query on a standby and the replay of the primary’s cleanup can need the same rows, and replay wins in the end. The run below reproduces the cancellation, prevents it with feedback and measures what that costs the primary.
In this part:
- Why replaying a cleanup cancels a standby query
- What standby feedback trades for fewer cancellations
1 runnable example (conflict_cleanup.sql, conflict_cleanup2.sql, conflict_feedback.sql, conflict_reader.sql, conflict_session.py, conflict_setup.sql, conflict_stats.sql, pg_session.py) · about 6 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
Warm-up, fresher to 2–5 years: what does pg_basebackup -R write, and how does the copy know it is a standby?
-R creates the empty file standby.signal in the copy and appends primary_conninfo to postgresql.auto.conf: a
connection string naming the primary’s host and port and, here, a password file. With -S it also writes
primary_slot_name. A server that finds standby.signal when it starts enters standby mode instead of starting as a
primary: it connects with primary_conninfo, receives the WAL and replays it. With hot_standby on, the default, it
answers read-only queries meanwhile, pg_is_in_recovery() returns true, and every write is refused.
Three harder cases, each with a first move, are in the module’s bank:
-
Mid to senior
A reporting standby goes down for a planned 10-hour storage migration. The primary writes about 4 GB of WAL an hour at peak, and its WAL volume has 60 GB of space left. How do you protect the primary, what happens if the migration runs over, and how does the standby come back in each case?
-
Mid to senior
Users of a booking service save a change on the primary, and the next page, served from a hot standby, sometimes still shows the old value. The standby is usually less than a second behind. How do you confirm the cause, and which fixes would you weigh, with what each costs?
-
Senior to staff
A 40-minute nightly report on a hot standby fails about once a week with "canceling statement due to conflict with recovery". The same standby is the failover target of a busy primary. A colleague proposes turning hot_standby_feedback on. What do you check first, what would that cost, and what would you do?
Flashcards · 4 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What is a hot standby, and how do you build one?
A copy of the whole cluster that follows the primary within moments and answers read-only queries. One command builds it: pg_basebackup -R -X stream -c fast -C -S <slot>. A standby.signal file makes a server enter standby mode, where only read-only transactions run.
-
What do pg_basebackup's -X, -c, -C -S and -R options add?
-X stream copies the WAL written during the backup so the copy is consistent; -c fast asks for an immediate checkpoint; -C -S create and use a replication slot so the primary keeps the needed WAL; -R writes standby.signal and the connection settings into postgresql.auto.conf.
-
What do the four LSN positions in pg_stat_replication mean?
sent_lsn, write_lsn, flush_lsn and replay_lsn — how far the standby has been sent, written, flushed and replayed the WAL. The gap between two is the replication lag. state = 'streaming' says nothing about replay, so read the gap to replay_lsn.
-
What do replication slots and synchronous settings control?
A slot makes the primary keep the WAL an absent standby has not received; max_slot_wal_keep_size caps it and PostgreSQL 18's idle_replication_slot_timeout retires unused slots. synchronous_standby_names and synchronous_commit decide which standbys a commit waits for and how far they must get.
Key takeaways
- A hot standby is a copy of the whole cluster that receives the primary’s WAL, replays it and answers read-only queries.
pg_basebackup -R -X stream -C -Scopies a running primary, creates its slot and writesstandby.signalwith the connection settings, so the copy starts as a standby.- A server in recovery refuses every write, and
pg_is_in_recovery()tells the two roles apart. - The primary’s
pg_stat_replicationshows each standby’s state and four positions; the gap between two positions is the lag in bytes, andstreamingalone says nothing about replay.
Terms in this lesson
- hot standby
- A standby server that answers read-only queries while it replays the primary's write-ahead log. The setting hot_standby is on by default, so a standby built with pg_basebackup is a hot standby.
- replication lag
- How far a standby is behind its primary: in bytes, as the gap between two WAL positions, or in time, from a commit on the primary until the standby has replayed it.
- streaming replication
- Replication in which a standby keeps a connection open to the primary and receives its write-ahead log records as they are written, instead of waiting for each WAL segment file to be filled and shipped.
References
- PostgreSQL 18 documentation: 26.2 Log-Shipping Standby Servers (26.2.5 Streaming Replication, 26.2.6 Replication Slots, 26.2.8 Synchronous Replication) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 26.4 Hot Standby (26.4.2 Handling Query Conflicts) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 19.6 Replication (19.6.1 Sending Servers, 19.6.2 Primary Server, 19.6.3 Standby Servers) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 19.5 Write Ahead Log (synchronous_commit) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: pg_basebackup (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 27.2 The Cumulative Statistics System (27.2.4 pg_stat_replication, 27.2.18 pg_stat_database_conflicts) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 53.20 pg_replication_slots (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 9.28 System Administration Functions (9.28.4 Recovery Control Functions) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 25.3.1 Setting Up WAL Archiving (a full pg_wal file system) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: F.33 pgstattuple (The PostgreSQL Global Development Group)
- PostgreSQL 18 release notes (idle_replication_slot_timeout) (The PostgreSQL Global Development Group)
- PostgreSQL 19 documentation (in preparation): WAIT (The PostgreSQL Global Development Group)
Report a problem with this lesson
Kept only in this browser. Your Learn progress