Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 15 minutes
  • Examples run with PostgreSQL 18.6 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 5 of 15 minutes free
Free: One command builds a standby that streams · Both servers report how far the standby has got · Practise: reading a standby from both servers. With a pass: 3 in-depth parts and 3 interview answers, about 10 minutes.

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 stream streams the WAL written during the copy alongside it, so the copy is consistent on its own.
  • -c fast asks for an immediate checkpoint, so the copy starts at once.
  • -C -S standby1 creates 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” standby1 and uses it, so the primary keeps the WAL the standby needs between the end of the copy and its first connection.
  • -R writes the file standby.signal and 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:

A standby made with one pg_basebackup, read from both servers

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:

Interactive · message by message One commit on its way from the primary to a standby Step through one INSERT on the primary until a query on the standby can see it, and see what each server does.
One commit on its way from the primary to a standby, step 6 of 6. Messages so far: Client to Primary: INSERT, COMMIT; Primary to Standby: WAL records; Standby to Primary: positions reached; Client to Standby: SELECT orders.Clientthe appPrimaryport 5432Standbychange replayedINSERT, COMMITWAL recordspositions reachedSELECT orders

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 In the run, what did pg_basebackup -R add to the copy so that it started as a standby?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The file standby.signal, and primary_conninfo with primary_slot_name in postgresql.auto.conf

    -R creates standby.signal and appends the connection settings to postgresql.auto.conf, and with -S it adds primary_slot_name. A server that finds standby.signal when it starts enters standby mode and uses them.

  2. Question 2 of 2 An application meant to write to the primary was given the standby's port by mistake. What does its first INSERT get?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: ERROR: cannot execute INSERT in a read-only transaction

    A hot standby answers reads and refuses every write while it is in recovery, so the application sees an error on each write and pg_is_in_recovery() returns true on that connection.

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_size caps what a slot may keep, and PostgreSQL 18’s idle_replication_slot_timeout retires 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_names names the standbys a commit waits for, and synchronous_commit says how far they must get.
  • max_standby_streaming_delay limits how long replay waits for a standby query, and hot_standby_feedback keeps the rows such queries read.

Checkpoint

Check: Reading a standby from both servers

  1. Question 1 of 2 What does state = streaming in pg_stat_replication tell you about a standby?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It caught up with the primary once and now receives WAL as it is written

    streaming follows catchup once a standby has caught up, and stays while WAL is sent as it is written. How far replay is behind is a separate reading: the gap between the positions, measured with pg_wal_lsn_diff().

  2. Question 2 of 2 On a primary, a standby shows sent_lsn = 0/5A000000 and replay_lsn = 0/58000000. How many MB of the WAL it was sent has the standby not replayed yet?

    Type a number.

    A hint

    Subtract the two positions as hexadecimal numbers; 16 MB is 0x01000000 bytes.

    Show the answer: Question 2 of 2

    Answer: 32 MB

    pg_wal_lsn_diff('0/5A000000', '0/58000000') is 0x02000000 bytes, 33,554,432, which is 32 MB: two 16 MB WAL segments that queries on the standby cannot see yet.

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.

  1. Question 1 of 6 Which options of the run's pg_basebackup create the replication slot the new standby uses?

    Choose one answer.

    Show the answer: Question 1 of 6

    Answer: -C -S standby1

    The run's command was pg_basebackup -D standby -R -X stream -c fast -C -S standby1: -C -S standby1 created the slot on the primary, and -R wrote primary_slot_name = 'standby1' into the copy.

  2. Question 2 of 6 You copied a primary with pg_basebackup -D copy -X stream, without -R. What must you add before starting the copy as a standby?

    Choose one answer.

    Show the answer: Question 2 of 6

    Answer: standby.signal, and primary_conninfo in its settings

    -R saves this work: it writes standby.signal and appends primary_conninfo, and primary_slot_name when -S names a slot, as the run's copy showed.

  3. Question 3 of 6 -X stream already copies the WAL written during the backup. Why does the run's command also use -C -S standby1?

    Choose one answer.

    Show the answer: Question 3 of 6

    Answer: So the primary keeps the WAL written after the copy until the standby connects and streams

    The copy is consistent on its own, but a standby that starts later needs the WAL written since. The slot makes the primary keep it, which is why the lesson builds the slot in the same command.

  4. Question 4 of 6 Which views on the primary show whether a standby is connected, how far it has got and what is kept for it?

    Choose one answer.

    Show the answer: Question 4 of 6

    Answer: pg_stat_replication and pg_replication_slots

    The run read both views on the primary: standby1 streaming and async with 0 kB of replay behind, and the slot standby1 active with wal_status reserved.

  5. Question 5 of 6 An async standby shows state = streaming, flush_lsn = 0/7000000 and replay_lsn = 0/6000000. What is true?

    Choose one answer.

    Show the answer: Question 5 of 6

    Answer: It has 16 MB on its disk that its queries cannot see yet

    Each position marks one step, so a gap between two of them names the step that is behind. Here the WAL was received and flushed, and replay trails it by 16 MB.

  6. Question 6 of 6 A dashboard that reads from a standby shows yesterday's numbers, and pg_stat_replication on the primary has no row for that standby at all. What do you conclude first?

    Choose one answer.

    Show the answer: Question 6 of 6

    Answer: The standby is not connected, so it receives no WAL; read its server log

    With no row there is no streaming at all. The standby's own log says why it cannot connect, and pg_replication_slots on the primary shows whether its slot is now inactive and keeping WAL.

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.

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:

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 4 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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 -S copies a running primary, creates its slot and writes standby.signal with 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_replication shows each standby’s state and four positions; the gap between two positions is the lag in bytes, and streaming alone 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.