Requirements
- PostgreSQL 16 or later. Logical decoding on a standby does not exist before 16.
wal_level = logicalon the primary and on the replica. See the warning below — the replica does not inherit this.hot_standby_feedback = onon the replica.- The wal2json output plugin installed on the replica’s host. It is a shared library, not a SQL extension, so it does not arrive through replication — it must be present on that machine.
- A single-instance endpoint for the replica, not a load-balanced reader endpoint.
- Superuser-equivalent access on the primary for the one-time
pgstream init.
⚠️wal_level = logicalis not inherited by the replica. If the primary sets it with a command line flag, the setting never reachespostgresql.confand so is not copied bypg_basebackup. The replica comes up atwal_level = replicaand slot creation fails withlogical decoding requires "wal_level" >= "logical". Set it explicitly on the replica.
⚠️ Point the source URL at a specific replica instance. The parallel data snapshot exports a transaction snapshot and imports it on sibling connections, and an exported snapshot is instance-local. An Aurora reader (cluster-ro) endpoint, an RDS reader endpoint, or a pooler spanning instances will fail nondeterministically. See Snapshots;pgstream checkflags this before a snapshot runs.
How streaming DDL works
pgstream’s DDL replication is stateless.pgstream init installs an event trigger
(pgstream.emit_ddl) that calls pg_logical_emit_message(), so DDL travels inline in the WAL
stream as a logical message rather than through a table that has to be read back.
You cannot install that event trigger on the replica because a standby is read only. But it’s not needed.
The event trigger runs on the primary, where DDL actually executes, and pg_logical_emit_message()
writes an ordinary WAL record. The replica replays that record like any other, so a logical slot on
the replica decodes it for free. No pgstream state of any kind is required on the replica.
Setup
1. Initialise pgstream on the primary — without a replication slot
emit_ddl event trigger, all of which
reach the replica through physical replication.
--migrations-only is important: without it, init also creates a logical replication slot
on the primary. In this setup nothing ever consumes that slot, so it would pin WAL on the
primary indefinitely — the exact disk pressure you are trying to avoid. See
CLI: init.
If you already created one, drop it:
⚠️ Use--slot-only. A plainpgstream destroywould also remove the pgstream schema and theemit_ddlevent trigger, which must stay on the primary.
⚠️ Two different slots live on the primary and only the slot_type column tells them apart.
Drop the unused logical slot; keep the replica’s own physical slot, which is what
stops the primary from discarding WAL the replica still needs.
2. Configure the replica
Both settings are required, and neither is inherited from the primary:3. Create the logical replication slot on the replica
--slot-only creates the replication slot and skips the schema and migrations, which is what
makes this work against a read only standby. See CLI: init.
The slot name only has to match what pgstream looks for on the source URL. If
--replication-slot and source.postgres.replication.replication_slot are both left empty,
pgstream derives pgstream_<dbname>_slot from the URL’s database name; since the replica is
a physical copy its database name is the same, so the default resolves identically on both
hosts.
⚠️ If this command does not return promptly, it is waiting, not failing. Creating a logical slot on a standby blocks until the primary emits anxl_running_xactsrecord, which is what lets the standby build a consistent catalog snapshot. An idle primary may never emit one. Unblock it by running this on the primary:
4. Run pgstream against the replica
Pointsource.postgres.url at the replica and run as normal. pgstream run never creates a
slot, it only connects to an existing one, so a missing or invalidated slot is a startup
failure rather than something that silently recreates itself.
Configuration
Nothing here is replica-specific except the URL: the source simply points at the replica.Operational considerations
-
Slot invalidation is the main risk. A logical slot on a standby can be invalidated by
recovery conflicts, or by the primary’s
max_slot_wal_keep_size. Invalidation means a full resnapshot, not a resume — a slot on the primary does not have this failure mode. Monitor it: -
hot_standby_feedbackholds back vacuum on the primary. It takes no locks there — it only moves the primary’s vacuum horizon — so it cannot cause the DDL lock contention that motivates moving the snapshot off the primary in the first place. What it does cost is bloat, and the cost differs sharply by phase:- Steady-state replication is cheap. A logical slot needs only
catalog_xmin, so the retained rows are in the system catalogs rather than user tables. - The snapshot phase is not. The parallel data snapshot holds a
REPEATABLE READtransaction open on the replica for as long as the snapshot runs, and that is a real reader with a dataxmin. While it is open the primary cannot vacuum the tables being snapshotted.
So budget for the primary not vacuuming the snapshotted tables for the duration of the snapshot, and let autovacuum catch up afterwards. Watchn_dead_tupon the largest tables. In the extreme — a horizon pinned long enough to trigger anti-wraparound autovacuum — DDL can end up blocked, because that flavour of autovacuum holdsSHARE UPDATE EXCLUSIVEand is not cancelled when a conflicting lock request arrives. - Steady-state replication is cheap. A logical slot needs only
-
max_standby_streaming_delayis the complementary lever. It protects long-running queries on the replica by pausing WAL replay instead of touching the primary’s horizon, trading replication lag for query survival. It does not replacehot_standby_feedbackfor a long-lived slot: delaying the conflicting WAL only defers the invalidation. - Failover. The slot is local to the replica, so promoting that node keeps it. Failing over to a different node, or rebuilding the replica, loses the slot and requires a new snapshot.
-
The primary is still required for DDL. DDL continues to execute on the primary and the
event trigger must remain installed there. Re-run
pgstream init --postgres-url <primary-url> --migrations-onlyafter any upgrade that adds migrations.