Resolving subscription initialization failures

Subscription initialization is the deterministic handshake — slot creation, publication negotiation, and the initial per-table COPY — that runs the instant…

Subscription initialization is the deterministic handshake — slot creation, publication negotiation, and the initial per-table COPY — that runs the instant CREATE SUBSCRIPTION executes, and this page isolates exactly why that handshake fails and how to recover it without stranding downstream state. When it stalls, the open replication slot pins WAL on the publisher while every consumer reads stale or half-copied rows, so a failed init is simultaneously a disk-exhaustion risk on the primary and a data-correctness risk on the subscriber. This is the catalog-level triage layer beneath the broader subscription sync procedures workflow, validated against PostgreSQL 14 through 17.

Treat every initialization failure as one of four vectors: publisher decoding surface not enabled, a connection or worker-slot ceiling exhausted, a publication/schema mismatch the subscriber cannot resolve, or a network fault that corrupts the large initial COPY mid-stream. The sections below map each vector to a concrete signature, a diagnostic query, and a revert-safe recovery.

Initialization Phase Semantics

Initialization failures overwhelmingly originate in publisher-side configuration that was never validated before the subscription was provisioned. The logical decoding subsystem will not spawn an apply worker until the publisher’s decoding surface and connection ceilings are in place, and PostgreSQL 13+ ships wal_level = replica, which explicitly blocks logical decoding. The following table maps each governing parameter to what it gates at CREATE SUBSCRIPTION time and the exact signature you get when it is under-provisioned.

Parameter Node Gates at init Under-provisioned signature Notes
wal_level publisher Whether WAL carries logical change records at all ERROR: logical decoding requires wal_level >= logical Change from replica/minimal requires a full cluster restart, not a reload.
max_replication_slots publisher Slot creation for the new subscription (one slot each) ERROR: could not create replication slot: slot limit reached Size against active + planned subscriptions; see configuring max_replication_slots safely.
max_wal_senders publisher The walsender backing the apply worker’s stream FATAL: number of requested standby connections exceeds max_wal_senders Must cover apply workers plus physical standbys, backups, and streaming slots.
max_worker_processes subscriber Background slots for the tablesync COPY workers ERROR: out of background worker slots Restart required to raise it.
max_logical_replication_workers subscriber Apply worker + the tablesync pool Tables sit at srsubstate = 'i', no data moves Too low: CREATE SUBSCRIPTION succeeds but relations queue forever.

Adjust the affected parameters in postgresql.conf (or via ALTER SYSTEM), reload with SELECT pg_reload_conf(); for the non-restart knobs, and confirm allocation with SHOW max_wal_senders; and SELECT slot_name, slot_type, active FROM pg_replication_slots; before retrying. The publication named in the subscription must also exist with exact casing, and every published table must already exist on the subscriber with a compatible REPLICA IDENTITY — logical replication never ships DDL, so schema parity is your responsibility.

Once prerequisites pass, initialization is a per-relation state machine tracked in pg_subscription_rel.srsubstate. The subscription is only fully consistent when every relation reads r; a single relation wedged at i or d means the initial COPY is blocked even though the subscription reports enabled.

Subscription table-sync states (srsubstate) from creation to steady-state streaming Four states are drawn as a left-to-right chain of boxes. A start marker labelled CREATE SUBSCRIPTION enters state 'i' (initialize), where the relation row is registered. An arrow labelled "start table sync" moves it to 'd' (data copy), which runs the initial COPY. An arrow labelled "initial COPY complete" moves it to 's' (synchronized), where the worker catches up to the live apply position. An arrow labelled "caught up to apply position" moves it to 'r' (ready), the steady streaming state. An exit marker labelled DROP SUBSCRIPTION leaves 'r'. States 'i' and 'd' are highlighted as the phases where a relation can wedge and block the whole initialization even though the subscription reports enabled. CREATE SUBSCRIPTION initialize srsubstate 'i' row registered start table sync data copy srsubstate 'd' initial COPY runs initial COPY complete synchronized srsubstate 's' catching up to apply caught up to apply position ready srsubstate 'r' streaming, consistent DROP SUB A relation wedged at 'i' or 'd' blocks initialization even while the subscription reports enabled.

Diagnostic Patterns

State lives across three catalogs — pg_subscription (definition), pg_subscription_rel (per-table state), and pg_stat_subscription (live worker + lag). A subscription can read enabled in the first while a relation is wedged in the second, so query all three during triage.

sql
-- 1. Apply-worker liveness. A NULL pid on an enabled subscription means the
--    launcher never spawned the worker: auth rejection, missing REPLICATION
--    privilege, or a publication-name mismatch on the publisher.
SELECT subname, pid, relid::regclass AS syncing_table,
       received_lsn, latest_end_lsn,
       last_msg_send_time, last_msg_receipt_time
FROM pg_stat_subscription
WHERE subname = 'orders_sub';
sql
-- 2. Per-table init progress. Any row below 'r' is not yet consistent.
--    i=init  d=data copy  f=finished  s=synced  r=ready(streaming)
SELECT srrelid::regclass AS table_name, srsubstate, srsublsn
FROM pg_subscription_rel sr
JOIN pg_subscription s ON s.oid = sr.srsubid
WHERE s.subname = 'orders_sub' AND sr.srsubstate <> 'r'
ORDER BY sr.srsubstate;
sql
-- 3. Publisher-side slot retention. A frozen restart_lsn while the sync is
--    incomplete is the disk-exhaustion warning: WAL cannot recycle until COPY finishes.
SELECT slot_name, active, restart_lsn, confirmed_flush_lsn,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots
WHERE slot_name = 'orders_sub';

Threshold guidance: a NULL pid on an enabled subscription is an immediate paging condition; a relation stuck below srsubstate = 'r' for longer than its benchmarked COPY time (roughly 1 GB/min on commodity storage) signals a blocked tablesync worker; and retained_wal climbing past 1 GB with active = false means the slot is bloating WAL and must be advanced or dropped. When a relation hangs in d, cross-reference pg_stat_activity for wait_event_type = Lock or wait_event = LogicalReplicationTableSyncWorker to find the blocking transaction. Map any concrete error text through the matrix below.

Error signature Root cause Deterministic resolution
FATAL: number of requested standby connections exceeds max_wal_senders max_wal_senders exhausted by apply workers plus physical/backup streams. Raise max_wal_senders by at least N + 2 (N = planned subscriptions); reload and verify with SHOW max_wal_senders;.
ERROR: could not create replication slot "sub_slot": slot limit reached max_replication_slots exhausted, or orphaned slots left by a failed CREATE SUBSCRIPTION. SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE active = false; then raise the ceiling if needed.
ERROR: publication "pub_name" does not exist Publication missing, case mismatch, or wrong dbname/search_path on the connection. Verify with SELECT pubname FROM pg_publication;; recreate with exact casing; align the subscriber DSN’s dbname.
ERROR: relation "schema.table" does not exist Subscriber lacks the target table, or the publication filter excludes it. Pre-create the schema; confirm inclusion via SELECT * FROM pg_publication_tables WHERE pubname = 'pub_name';.
could not receive data from WAL stream: SSL error: decryption failed or bad record mac MTU mismatch, aggressive TCP idle timeout, or TLS renegotiation during a large COPY. Set tcp_keepalives_idle, tcp_keepalives_interval, tcp_keepalives_count on both nodes.
ERROR: logical decoding requires wal_level >= logical Publisher wal_level is replica or minimal. Update postgresql.conf, restart the server, verify with SHOW wal_level;.

Authentication and reachability failures — the most common cause of a NULL apply-worker pid — are governed by the publisher’s pg_hba.conf; the full replication-role and host-based auth model is covered in setting up pg_hba.conf for replication users.

Safe Recovery Sequence

When initialization fails mid-copy, PostgreSQL leaves the slot inactive and marks the affected pg_subscription_rel rows at i or d. Never delete rows from system catalogs by hand — that desynchronizes the subscription from its slot and forces a full re-seed. Follow this revert-safe sequence instead.

Revert-safe recovery decision flow for a failed subscription initialization A vertical spine of three decision diamonds, each kicking a remediation box out to the right. Gate 1 asks whether the apply-worker pid is NULL; "yes" leads to fixing pg_hba.conf and auth, granting REPLICATION, then re-enabling; "no" descends the spine. Gate 2 asks whether a relation is stuck at 'i' or 'd'; "yes" leads to raising max_worker_processes and max_logical_replication_workers or clearing the blocking lock; "no" descends. Gate 3 asks whether the inactive slot is salvageable; "salvageable" leads to pg_replication_slot_advance then REFRESH PUBLICATION and ENABLE; "orphaned or WAL exceeded" leads to DROP SUBSCRIPTION and re-seed with copy_data=true. All four remediation boxes feed a dashed re-verify rail on the right that loops back down into the terminal goal: all relations reach srsubstate = 'r', consistent and streaming, with the slot recycling WAL again. apply-worker pid = NULL? yes 1 · Fix pg_hba.conf / auth, grant REPLICATION then ALTER SUBSCRIPTION … ENABLE no relation stuck at 'i' or 'd'? yes 2 · Raise worker-slot ceilings max_worker_processes / max_logical_replication_workers, or clear lock no inspect slot inactive slot salvageable? salvage 3a · pg_replication_slot_advance then REFRESH PUBLICATIONENABLE orphaned / WAL exceeded 3b · DROP SUBSCRIPTION → recreate re-seed with copy_data = true re-verify diagnostics All relations reach srsubstate = 'r' subscription consistent · slot recycling WAL again

1. Halt the apply worker so no partial changes land while you triage.

sql
ALTER SUBSCRIPTION orders_sub DISABLE;

2. Confirm the slot is inactive on the publisher before touching its position.

sql
-- On the publisher.
SELECT slot_name, active, restart_lsn
FROM pg_replication_slots
WHERE slot_name = 'orders_sub';

3. Advance a stale slot to the current WAL position only if the slot retains old LSNs and you intend to re-seed rather than resume. This prevents immediate WAL bloat on restart. Skip this step if you plan to resume an in-progress COPY, because advancing past unconsumed WAL discards changes.

sql
-- On the publisher. Advancing discards WAL between restart_lsn and now — re-seed after.
SELECT pg_replication_slot_advance('orders_sub', pg_current_wal_lsn());

4. Reset subscriber-side state so the apply worker re-plans which relations still need COPY.

sql
ALTER SUBSCRIPTION orders_sub REFRESH PUBLICATION WITH (copy_data = true);

5. Re-enable and watch every relation converge to r using diagnostic query 2 above.

sql
ALTER SUBSCRIPTION orders_sub ENABLE;

Revert / escalation. If the slot cannot be salvaged — orphaned by a partitioned CREATE SUBSCRIPTION, or bloating WAL past max_slot_wal_keep_size — the clean path is DROP SUBSCRIPTION orders_sub; (which drops the publisher slot) and recreate. For a subscriber that already holds a byte-identical baseline (for example restored from the same pg_dump the slot was created against), recreate with copy_data = false to skip the COPY entirely and stream from the slot’s current position; using false when the baseline does not match leaves those tables permanently empty with no error.

Pipeline Integration

Python ETL controllers and event-streaming consumers must treat CREATE SUBSCRIPTION as a fallible, retryable operation, never a fire-and-forget DDL call. When driving initialization from psycopg2 or asyncpg, wrap creation so a partially-applied attempt is idempotent on retry, and resume from the last acknowledged LSN rather than re-requesting an arbitrary earlier position.

python
# Idempotent init with bounded backoff + jitter. Safe to re-run after a
# transient publisher restart without leaving orphaned slots.
import time, random, psycopg2
from psycopg2 import errors

def ensure_subscription(dsn: str, ddl: str, attempts: int = 6) -> None:
    backoff = 0.5
    for _ in range(attempts):
        try:
            with psycopg2.connect(dsn) as conn:
                conn.autocommit = True
                with conn.cursor() as cur:
                    cur.execute(ddl)          # CREATE SUBSCRIPTION ... WITH (...)
            return
        except errors.DuplicateObject:
            return                            # already created — idempotent success
        except (errors.ObjectInUse, psycopg2.OperationalError):
            time.sleep(backoff + random.random() * 0.25)  # jitter avoids thundering herd
            backoff = min(backoff * 2, 30)    # cap at 30 s across cluster restarts
    raise TimeoutError("subscription did not initialize within retry budget")

Export initialization health to your monitoring layer so a stalled COPY pages before it exhausts publisher disk: alert when pg_stat_subscription.pid IS NULL on an enabled subscription, when any pg_subscription_rel.srsubstate <> 'r' persists beyond the relation’s benchmarked COPY window, or when last_msg_send_time < NOW() - INTERVAL '5 minutes'. The reusable postgres_exporter queries, Prometheus alert rules, and Grafana panels for these signals live in asynchronous monitoring integration. Once a subscription reaches steady state, its apply durability is governed separately by synchronous_commit for logical replication.

When the consumer is an event-streaming pipeline rather than a native subscription, the same publication and slot are read by the Debezium connector or a custom reader — in which case the initial-snapshot and offset-tracking responsibilities described here move into the connector’s own state store, and the low-level frame handling is covered in parsing pgoutput format with psycopg2. The LSN and cursor arithmetic that every threshold on this page depends on is detailed in WAL stream mechanics.

Authoritative references