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

Logical replication and change data capture

Publish PostgreSQL tables with row filters, subscribe from a second cluster, resolve apply conflicts with 18's conflict counters, and stream changes for CDC.

  • Advanced
  • 20 minutes
  • Examples run with PostgreSQL 18.6 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 5 of 20 minutes free
Free: Logical replication sends row changes, not disk blocks · One publication, one subscription, and what stays behind · Change data capture reads the same stream · Practise: which statements reach the subscriber. With a pass: 3 in-depth parts and 3 interview answers, about 15 minutes.

What you will learn

  • Publish a table and subscribe to it from a second cluster, and name what logical replication does not copy
  • Choose a row filter, a column list and a replica identity that let UPDATE and DELETE replicate
  • Resolve an apply conflict from the subscriber's log and PostgreSQL 18's conflict counters with the least data lost
  • Capture committed changes with logical decoding and keep its slot from holding back WAL and catalog rows

Before you start

What you’ll learn · Before you start

What you will learn

  • Publish a table and subscribe to it from a second cluster, and name what logical replication does not copy
  • Choose a row filter, a column list and a replica identity that let UPDATE and DELETE replicate
  • Resolve an apply conflict from the subscriber's log and PostgreSQL 18's conflict counters with the least data lost
  • Capture committed changes with logical decoding and keep its slot from holding back WAL and catalog rows

Before you start

On this page

A reporting cluster needs only the orders table of a 400 GB primary, and a search index must learn of every new order within seconds. A streaming standby would copy all 400 GB, every database, block for block. logical replication: Replication of row changes: the publisher decodes its write-ahead log into the committed inserts, updates and deletes of chosen tables, and a subscriber applies them to tables of its own, even on another major version. sends the row changes of the tables you choose to another PostgreSQL, which applies them as ordinary inserts, updates and deletes. change data capture: Turning every committed change of a database into a stream of events that other systems read in commit order. In PostgreSQL it reads a logical slot through an output plugin. reads the same decoded changes into a program of your own, such as a queue or a search index.

You will publish a table on one PostgreSQL 18.6 cluster, subscribe to it from a second, and read in a recorded run what arrived and what did not.

Logical replication sends row changes, not disk blocks

A standby replays the WAL as it is, so it holds every database, accepts no writes and runs the same major version. Logical replication reads the same WAL but decodes it first, into the committed row changes of the tables you picked, and the subscriber applies them to tables of its own. That subscriber is an ordinary cluster: it accepts writes, keeps indexes of its own and may run another major version, which Upgrading PostgreSQL: minor and major puts to use.

Four parts carry a change. A publication: A named set of tables on a publisher, created with CREATE PUBLICATION, whose row changes logical replication sends to the subscriptions that name it. Creating one starts nothing by itself. on the publisher names the tables to send. A subscription: The receiving side of logical replication, created with CREATE SUBSCRIPTION: a connection to a publisher and the publications to receive, with a replication slot on the publisher and an apply worker that writes the changes. on the subscriber names the connection and the publications, and creates a 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” on the publisher that remembers how far it has read. A 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” decodes the WAL with the output plugin pgoutput, and an apply worker on the subscriber writes what arrives. The publisher needs wal_level = logical, which takes a restart; PostgreSQL 19, still in preparation, can switch logical decoding on by itself at wal_level = replica. Follow one row change of the next section’s run:

Interactive · stage by stage One row change on its way from publisher to subscriber Step through the path of the run's UPDATE of order 1, from its commit on the publisher to the subscriber's table.
One row change on its way from publisher to subscriber, step 7 of 7. Showing Commit on the publisher, A WAL record, The slot shop_sub, Walsender with pgoutput (tables of shop_pub only), The apply worker, The subscriber's orders (order 1: net 1200.00). In focus: The subscriber's orders.confirms how far it gotCommit on the publisherUPDATE of order 1A WAL recordat wal_level logicalThe slot shop_subkeeps WAL until confirmedWalsender with pgoutputtables of shop_pub onlyThe apply workeron the subscriberThe subscriber's ordersorder 1: net 1200.00

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

Every step as text
  1. The publisher commits. The run's UPDATE of order 1, net 1000.00 to 1200.00, commits on the publisher. The subscriber's copy of the row can change only after this commit.
  2. The change goes into the WAL. Like every change, the UPDATE is written to the WAL first. With wal_level = logical the record also holds what logical decoding needs to rebuild the change as a row of orders.
  3. The slot keeps its place. CREATE SUBSCRIPTION created the logical slot shop_sub on the publisher. It records how far the subscriber has confirmed, so the publisher keeps the WAL from that point on, even while the subscriber is away.
  4. Decoded and filtered. The walsender serving the subscription reads the WAL through the slot. Its output plugin, pgoutput, turns the records into row changes, keeps the tables of the publication shop_pub and sends each transaction in commit order.
  5. Applied as row changes. The apply worker on the subscriber receives the transaction and applies its change to the local orders table. It works out the generated tax column, 216.00, from its own definition of the table.
  6. Progress goes back. The apply worker tells the publisher how far it has applied. Only then can the slot move forward, and the publisher remove WAL that the subscriber no longer needs.
  7. The row has arrived. Order 1 on the subscriber now reads net 1200.00 and tax 216.00, as on the publisher. The ALTER TABLE that came after it did not travel, because the stream carries row changes and no schema.

Only committed row changes travel, in commit order, and everything else stays where it was made.

Checkpoint

Check: What logical replication sends

  1. Question 1 of 2 What does a logical replication subscriber receive from its publisher?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The committed row changes of the published tables, in commit order

    Logical replication turns WAL into committed row changes of the published tables, and the apply worker writes them as ordinary inserts, updates and deletes.

  2. Question 2 of 2 Which of these can a logical replication subscriber do that a streaming standby cannot?

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • Keep indexes that the publisher does not have
    • Run another major version than the publisher
    • Accept writes to its own tables

    A subscriber is a writable cluster with its own tables and version; a standby is a byte-for-byte, read-only copy of the whole cluster.

One publication, one subscription, and what stays behind

The run starts a publisher on port 5432 and a subscriber on port 5434. It fills orders on the publisher and runs CREATE PUBLICATION shop_pub FOR TABLE orders; on the subscriber it creates the same table by hand and runs CREATE SUBSCRIPTION shop_sub CONNECTION … PUBLICATION shop_pub. Then it changes rows on the publisher and reads both sides:

A publication, a subscription and three row changes between two clusters

replicate_session.py

"""Logical replication between two PostgreSQL 18.6 clusters: a publisher with wal_level = logical, and a subscriber.

publish.sql creates the orders table on the publisher, fills it and publishes it. subscribe.sql creates the same table on
the subscriber, which receives no schema from the publisher, and subscribes to the publication, which copies the three
rows first. changes.sql then updates, deletes and inserts a row on the publisher and adds a column there;
publisher_side.sql and subscriber_side.sql read the rows, the sequence behind id and the replication slot on each side.

The subscription's connection string reaches psql as the variable pub, so the record shows CONNECTION :'pub' and never a
host, a port or a password; the publisher is shown on port 5432 and the subscriber on port 5434. The session waits until
the subscriber has applied every change and the initial copy's temporary slot is gone, so nothing in the record differs
between runs and nothing is masked.
"""

from pg_session import Server

COMMON = {"autovacuum": "off", "jit": "off", "timezone": "'UTC'", "log_line_prefix": "''",
          "max_parallel_workers_per_gather": 0}
# wal_level = logical is needed on the publisher only, and it takes a server restart to change.
PUBLISHER = {**COMMON, "wal_level": "logical"}
# --no-sync: initdb does not flush the new clusters to disk, which only saves time; they are deleted after the run.
FAST = ("--no-sync",)

with Server(settings=PUBLISHER, initdb=FAST) as pub, Server(settings=COMMON, initdb=FAST, shown_port=5434) as sub:
    pub.up()
    sub.up()
    pub.psql("publish.sql", note="on the publisher, port 5432")
    sub.psql("subscribe.sql", variables=[("pub", pub.conninfo(db="postgres"))], note="on the subscriber, port 5434")
    sub.wait_for("SELECT count(*) FROM orders", "3")
    pub.psql("changes.sql", note="on the publisher again")
    sub.wait_for("SELECT count(*) FROM orders WHERE region = 'east'", "1")
    sub.wait_for("SELECT count(*) FROM pg_subscription_rel WHERE srsubstate <> 'r'", "0")
    pub.wait_for("SELECT count(*) FROM pg_replication_slots", "1")
    pub.psql("publisher_side.sql", note="the publisher's side")
    sub.psql("subscriber_side.sql", note="the subscriber's side")

Output

-- on the publisher, port 5432
$ psql -X -a -f publish.sql
-- The publisher must write WAL at the logical level.
SHOW wal_level;
 wal_level 
-----------
 logical
(1 row)

CREATE TABLE orders (
    id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    region text NOT NULL,
    net    numeric(12,2) NOT NULL,
    tax    numeric(12,2) GENERATED ALWAYS AS (round(net * 0.18, 2)) STORED
);
CREATE TABLE
INSERT INTO orders (region, net) VALUES ('south', 1000), ('north', 2500), ('west', 400);
INSERT 0 3
-- A publication names what to send. Creating it sends nothing yet.
CREATE PUBLICATION shop_pub FOR TABLE orders;
CREATE PUBLICATION

-- on the subscriber, port 5434
$ psql -X -a -f subscribe.sql
-- No schema comes from the publisher: the table is created here by hand, column for column.
CREATE TABLE orders (
    id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    region text NOT NULL,
    net    numeric(12,2) NOT NULL,
    tax    numeric(12,2) GENERATED ALWAYS AS (round(net * 0.18, 2)) STORED
);
CREATE TABLE
-- The connection string arrives as a psql variable, so this file holds no host and no password.
CREATE SUBSCRIPTION shop_sub CONNECTION :'pub' PUBLICATION shop_pub;
psql:subscribe.sql:9: NOTICE:  created replication slot "shop_sub" on publisher
CREATE SUBSCRIPTION

-- on the publisher again
$ psql -X -a -f changes.sql
-- Three row changes: each one is sent when its transaction commits.
UPDATE orders SET net = 1200 WHERE id = 1;
UPDATE 1
DELETE FROM orders WHERE id = 3;
DELETE 1
INSERT INTO orders (region, net) VALUES ('east', 800);
INSERT 0 1
-- A schema change stays on this side.
ALTER TABLE orders ADD COLUMN note text;
ALTER TABLE

-- the publisher's side
$ psql -X -a -f publisher_side.sql
SELECT * FROM orders ORDER BY id;
 id | region |   net   |  tax   | note 
----+--------+---------+--------+------
  1 | south  | 1200.00 | 216.00 | 
  2 | north  | 2500.00 | 450.00 | 
  4 | east   |  800.00 | 144.00 | 
(3 rows)

-- The sequence behind the identity column, and the slot the subscription created here.
SELECT last_value FROM pg_sequences WHERE sequencename = 'orders_id_seq';
 last_value 
------------
          4
(1 row)

SELECT slot_name, plugin, slot_type, active FROM pg_replication_slots;
 slot_name |  plugin  | slot_type | active 
-----------+----------+-----------+--------
 shop_sub  | pgoutput | logical   | t
(1 row)

-- the subscriber's side
$ psql -X -a -f subscriber_side.sql
SELECT * FROM orders ORDER BY id;
 id | region |   net   |  tax   
----+--------+---------+--------
  1 | south  | 1200.00 | 216.00
  2 | north  | 2500.00 | 450.00
  4 | east   |  800.00 | 144.00
(3 rows)

-- The subscriber's own sequence: null means it has never handed out a value.
SELECT last_value FROM pg_sequences WHERE sequencename = 'orders_id_seq';
 last_value 
------------
           
(1 row)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 replicate_session.py

publish.sql

-- The publisher must write WAL at the logical level.
SHOW wal_level;
CREATE TABLE orders (
    id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    region text NOT NULL,
    net    numeric(12,2) NOT NULL,
    tax    numeric(12,2) GENERATED ALWAYS AS (round(net * 0.18, 2)) STORED
);
INSERT INTO orders (region, net) VALUES ('south', 1000), ('north', 2500), ('west', 400);
-- A publication names what to send. Creating it sends nothing yet.
CREATE PUBLICATION shop_pub FOR TABLE orders;

subscribe.sql

-- No schema comes from the publisher: the table is created here by hand, column for column.
CREATE TABLE orders (
    id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    region text NOT NULL,
    net    numeric(12,2) NOT NULL,
    tax    numeric(12,2) GENERATED ALWAYS AS (round(net * 0.18, 2)) STORED
);
-- The connection string arrives as a psql variable, so this file holds no host and no password.
CREATE SUBSCRIPTION shop_sub CONNECTION :'pub' PUBLICATION shop_pub;

changes.sql

-- Three row changes: each one is sent when its transaction commits.
UPDATE orders SET net = 1200 WHERE id = 1;
DELETE FROM orders WHERE id = 3;
INSERT INTO orders (region, net) VALUES ('east', 800);
-- A schema change stays on this side.
ALTER TABLE orders ADD COLUMN note text;

publisher_side.sql

SELECT * FROM orders ORDER BY id;
-- The sequence behind the identity column, and the slot the subscription created here.
SELECT last_value FROM pg_sequences WHERE sequencename = 'orders_id_seq';
SELECT slot_name, plugin, slot_type, active FROM pg_replication_slots;

subscriber_side.sql

SELECT * FROM orders ORDER BY id;
-- The subscriber's own sequence: null means it has never handed out a value.
SELECT last_value FROM pg_sequences WHERE sequencename = 'orders_id_seq';

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)

The subscription created the slot shop_sub, which the publisher lists with the plugin pgoutput. The three rows came first, as an initial copy; the update of order 1, the delete of order 3 and the insert of order 4 followed as changes, and both sides end with the same three rows. The subscriber worked out tax from its own column definition, because generated columns travel only when a publication asks for them. Three things stayed behind:

  • The schema. The subscriber’s table exists because the run created it, and the column note added on the publisher never arrived: DDL is not replicated.
  • The sequence. orders_id_seq reached 4 on the publisher and was never used on the subscriber, so a subscriber that took over would hand out id 1 again. PostgreSQL 19 can copy sequence values.
  • Whatever the publication does not name, from other tables to views and large objects.

Adding a column on the publisher first

The next change that carries the new column stops the subscription until the subscriber’s table has it too. Add the column on the subscriber first, then on the publisher.

Checkpoint

Check: A publication, a subscription and what stays behind

  1. Question 1 of 2 After the run, the application inserts a row with a note into orders on the publisher, where ALTER TABLE added the column note. The subscriber's table has no such column. What happens?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The subscription stops at that change until the subscriber's table gets the column

    A schema change has to reach the subscriber by hand, and an added column should reach it first, before any change that carries it.

  2. Question 2 of 2 The publisher of the run is lost, and the application starts writing to the subscriber. What does its first INSERT INTO orders (region, net) do there?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It fails with a duplicate key, because the subscriber's sequence hands out id 1

    Before a subscriber takes over, set every sequence above the highest value its table holds, for example with setval from the publisher's last value; PostgreSQL 19 can copy sequence values instead.

Change data capture reads the same stream

Change data capture turns every committed change into an event that other systems read in commit order. In PostgreSQL it rests on logical decoding: The step that turns the write-ahead log into committed row changes, read through a logical replication slot and an output plugin. Logical replication and change data capture both rest on it., the step of logical replication that turns WAL into row changes, and it needs three pieces: a logical slot of its own, an output plugin that writes the consumer’s format, and a client that reads the slot and confirms what it has stored. Step through one log that feeds both kinds of reader:

Interactive · step by step One log, two kinds of reader Step through how a subscription and a change data capture consumer each read the same WAL through a slot of their own.
One log, two kinds of reader, step 5 of 5. Showing The publisher's WAL, Slot shop_sub, Slot cdc, pgoutput, test_decoding, Apply worker, pg_recvlogical, The subscriber's orders, Your program. In focus: Slot shop_sub, Slot cdc.The publisher's WALeach change written onceSlot shop_subfor the subscriptionSlot cdcfor your consumerpgoutputthe publication's tablestest_decodingone line per changeApply workeron the subscriberpg_recvlogicalships with PostgreSQLThe subscriber's ordersrows appliedYour programa queue or an index

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

Every step as text
  1. One log. The publisher writes each committed change into its WAL once. Any number of logical slots can read that log, each one for a single consumer and at that consumer's pace.
  2. The subscription's slot. The slot shop_sub belongs to the run's subscription. Its plugin, pgoutput, decodes the changes of the publication's tables, and the apply worker writes them into the subscriber's orders.
  3. A slot of your own. Change data capture creates a slot for its own consumer, here cdc with the plugin test_decoding, which writes each change as one line of text.
  4. A client reads it. pg_recvlogical, which ships with PostgreSQL, reads the slot and hands the lines on, to a file or to your program, such as a queue or a search index.
  5. Each keeps its own place. Each slot records how far its own consumer has confirmed, so a slow consumer never holds up the other one. Both read the same committed changes, in commit order.

The right-hand column is what a change data capture tool runs. The parts after the practice add five tools:

  • Row filters and column lists send only the rows that match a condition and only the columns you name.
  • The replica identity: The set of columns that identifies the old row of an UPDATE or DELETE in logical replication, written into the WAL so that the subscriber can find the row; by default the table's primary key. is the set of columns that identifies the old row of an UPDATE or DELETE, by default the primary key.
  • PostgreSQL 18’s conflict statistics count each kind of apply conflict, and ALTER SUBSCRIPTION … SKIP skips one remote transaction that cannot be applied.
  • Failover slots let a subscription carry on from a promoted standby, and pg_createsubscriber turns a physical standby into a subscriber without a first copy.
  • pg_recvlogical with an output plugin streams the changes of a logical slot into a file or another program.

Checkpoint

Check: Change data capture and logical decoding

  1. Question 1 of 2 Which pieces does a change data capture consumer of PostgreSQL rely on?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • A client that reads the slot and confirms what it has stored
    • An output plugin that writes the format the consumer reads
    • A logical slot of its own on the source server

    Change data capture in PostgreSQL is logical decoding: a slot, an output plugin and a client that reads and confirms.

  2. Question 2 of 2 A search index must reflect every committed change to a products table within seconds, deletes included, and the team wants no triggers on the table. Which approach fits?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Read a logical slot through an output plugin and send each change to the index

    Change data capture serves systems that are not PostgreSQL with the same decoded changes that logical replication applies.

Practise: which statements reach the subscriber

The questions use the run’s two clusters and its publication of orders. Decide what the subscriber shows, or which piece does the work, before you open each answer.

Practice that adapts to you

Practise this lesson

8 questions about 12 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 8 The publication of the run is FOR TABLE orders, with the default settings. Which of these statements on the publisher change what the subscriber shows?

    Choose every answer that is right.

    Show the answer: Question 1 of 8

    Answer:

    • INSERT INTO orders (region, net) VALUES ('west', 300)
    • TRUNCATE orders
    • UPDATE orders SET net = 900 WHERE id = 2
    • DELETE FROM orders WHERE id = 4

    A publication sends the row changes of its tables, truncates included by default; schema changes, sequences and other tables stay on the publisher.

  2. Question 2 of 8 A transaction on the publisher inserts 10 orders and then rolls back. What does the subscriber's table show?

    Choose one answer.

    Show the answer: Question 2 of 8

    Answer: None of the 10 orders

    Logical replication carries committed row changes, so a rolled-back transaction leaves no trace on the subscriber.

  3. Question 3 of 8 Which setting must a PostgreSQL 18 publisher have before a subscription can stream from it?

    Choose one answer.

    Show the answer: Question 3 of 8

    Answer: wal_level = logical

    wal_level = logical takes a restart of the publisher, so it is set before the first subscription.

  4. Question 4 of 8 Where does the replication slot of a subscription live?

    Choose one answer.

    Show the answer: Question 4 of 8

    Answer: On the publisher

    The slot keeps the publisher's WAL from the subscriber's last confirmed position, so it has to be on the publisher.

  5. Question 5 of 8 What does CREATE SUBSCRIPTION do with the rows a published table already holds?

    Choose one answer.

    Show the answer: Question 5 of 8

    Answer: It copies them first, then applies the changes made since

    The initial copy brings the existing rows, and the slot then delivers every change made after it.

  6. Question 6 of 8 Where did the subscriber's tax of 216.00 for order 1 come from in the run?

    Choose one answer.

    Show the answer: Question 6 of 8

    Answer: The subscriber computed it from its own definition of the generated column

    The subscriber stored 216.00 because its own tax is generated as net × 0.18, the same expression as on the publisher.

  7. Question 7 of 8 What does an output plugin do in logical decoding?

    Choose one answer.

    Show the answer: Question 7 of 8

    Answer: It turns decoded changes into the format the consumer reads

    Logical decoding needs a slot, an output plugin and a client; the plugin decides the format of the stream.

  8. Question 8 of 8 A nightly job copies rows whose updated_at is newer than its last run into a warehouse. Why does change data capture through a slot catch changes that this job misses?

    Choose one answer.

    Show the answer: Question 8 of 8

    Answer: It reads every committed change from the WAL, deletes included, whatever columns it touched

    Change data capture follows the log of changes, not a column the application has to remember to update.

Row filters, column lists and the replica identity, with a failure reproduced Pro: opens with a Pro, Premium or Ultimate pass

A row filter and a column list make a publication send less, and both depend on the replica identity. This part reproduces the failure that a filter on the wrong column causes and measures, in bytes of WAL, what each way out costs.

In this part:

  • What a row filter and a column list send
  • The UPDATE that fails after a valid filter, reproduced
  • What to configure instead, measured in WAL bytes

2 runnable examples (identity_after.sql, identity_chain.sql, identity_publish.sql, identity_session.py, identity_subscribe.sql, pg_session.py, walcost_measure.sql, walcost_session.py, walcost_setup.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).

Apply conflicts in PostgreSQL 18: logged, counted and resolved Pro: opens with a Pro, Premium or Ultimate pass

A subscriber accepts writes, so a row written there can collide with a change from the publisher, and the apply worker stops at the first change it cannot apply. This part records one such conflict on PostgreSQL 18.6, reads every line and counter it left behind, and resolves it, including what a skip costs.

In this part:

  • One conflict recorded, from the log line to the skip
  • Seven counters, and the conflicts that never stop apply
  • Fix the row first, and skip only what must go

1 diagram · 1 runnable example (conflict_local.sql, conflict_more.sql, conflict_publish.sql, conflict_remote.sql, conflict_session.py, conflict_skip.sql, conflict_stats.sql, conflict_subscribe.sql) · about 8 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Logical decoding for change data capture, and slots that stay healthy Pro: opens with a Pro, Premium or Ultimate pass

Change data capture is logical decoding with a consumer of your own, and its weak point is the slot that consumer leaves behind. This part streams committed changes with pg_recvlogical, measures what a slot nobody reads holds back, and sets up the failover slots and the plugin list that keep a stream working.

In this part:

  • Streaming one transaction through an output plugin
  • What a slot nobody reads holds back, measured
  • Failover slots, a standby turned subscriber, and the plugin list
  • Choosing a standby, a subscriber or a change stream

1 runnable example (cdc_changes.sql, cdc_forgotten.sql, cdc_session.py, cdc_setup.sql, pg_session.py) · about 12 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Interview questions

Three cases to rehearse, each with its first move in the bank: a publication change the application notices, a subscriber halted by one row, and an unread change stream.

  1. Mid to senior

    On a Friday evening a colleague adds WHERE (region = 'south') to the publication that feeds your reporting cluster from a 300 GB orders table. Within a minute every UPDATE and DELETE on orders fails in the application. Explain what happened, how you restore writes at once, and which lasting fix you choose.

    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

    A reporting subscriber stopped applying changes at 02:10 after a duplicate key error on customers. At 09:00 the publisher's slot holds 180 GB of WAL, its disk has 60 GB left, and it writes 3 GB of WAL an hour. Walk through how you get replication running again without losing data, and in which order.

    Model answer, Pro. First step and follow-ups

  3. Senior to staff

    A change data capture connector reads a logical slot through its own output plugin. After the minor update to 18.6 the connector cannot start, and by the next morning pg_wal fills 85 % of the primary's 500 GB disk. Explain both events, what you fix first, and how you prevent a repeat.

    Model answer, Pro. First step and follow-ups

Flashcards · 3 cards

Recall it, then check

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

  1. How does logical replication differ from streaming replication?

    It decodes the WAL into the committed row changes of the tables you choose, and the subscriber applies them as ordinary writes. The subscriber is a writable cluster that can run another major version. Parts: a publication, a subscription, a walsender using pgoutput, and an apply worker; the publisher needs wal_level = logical.

  2. What travels with logical replication, and what stays behind?

    Only committed row changes, in commit order. Left behind: the schema (DDL is not replicated), sequences (PostgreSQL 19 can copy them), and anything the publication does not name. Add a new column on the subscriber first, then on the publisher.

  3. What does change data capture need on top of logical decoding?

    A logical slot of its own, an output plugin that writes the consumer's format, and a client that reads the slot and confirms what it has stored. pg_recvlogical streams a slot's changes; the replica identity is the columns that identify an old row (default the primary key).

Key takeaways

  • Logical replication decodes the publisher’s WAL into committed row changes of the published tables, which an apply worker writes on a writable subscriber.
  • A publication names the tables; a subscription copies the existing rows and keeps a logical slot on the publisher, which needs wal_level = logical.
  • The schema, DDL and, before PostgreSQL 19, sequence values stay behind and are kept in step by hand.
  • Change data capture reads the same decoded changes through a slot and an output plugin of its own, for example with pg_recvlogical.

Terms in this lesson

change data capture
Turning every committed change of a database into a stream of events that other systems read in commit order. In PostgreSQL it reads a logical slot through an output plugin.
logical decoding
The step that turns the write-ahead log into committed row changes, read through a logical replication slot and an output plugin. Logical replication and change data capture both rest on it.
logical replication
Replication of row changes: the publisher decodes its write-ahead log into the committed inserts, updates and deletes of chosen tables, and a subscriber applies them to tables of its own, even on another major version.
publication
A named set of tables on a publisher, created with CREATE PUBLICATION, whose row changes logical replication sends to the subscriptions that name it. Creating one starts nothing by itself.
replica identity
The set of columns that identifies the old row of an UPDATE or DELETE in logical replication, written into the WAL so that the subscriber can find the row; by default the table's primary key.
subscription
The receiving side of logical replication, created with CREATE SUBSCRIPTION: a connection to a publisher and the publications to receive, with a replication slot on the publisher and an apply worker that writes the changes.

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.