Initializing Replication Slots

A logical replication slot is the durable Write-Ahead Log (WAL) cursor that makes Change Data Capture (CDC) survivable, and provisioning it correctly is the…

A logical replication slot is the durable Write-Ahead Log (WAL) cursor that makes Change Data Capture (CDC) survivable, and provisioning it correctly is the second operational step in the Logical Replication Setup & Management workflow — once a publication has fixed what leaves the primary, the slot fixes from where the stream resumes and how much WAL the server must retain until a consumer confirms receipt. For database engineers, data platform teams, and Python ETL developers running PostgreSQL 14 through 17, pg_create_logical_replication_slot is not a throwaway one-liner: it decides your pipeline’s restart guarantee, your pg_wal growth ceiling, and whether a stalled consumer degrades gracefully or takes the primary offline.

Get slot initialization wrong and the failure is asymmetric. Create a slot and never attach a consumer, and it silently pins restart_lsn forever — WAL accumulates at the full write rate of the primary until the disk fills and the database refuses to accept commits. Create it with the wrong output plugin and the consumer either fails to decode the stream or, worse, receives a wire format it half-understands. Drop a slot to “free space” during an incident and you have destroyed the only cursor that let the consumer resume without a full re-snapshot. Because a slot lives inside the decoding path on the publisher and its retention is enforced by the checkpointer, each of these mistakes converts directly into unbounded disk usage, replication lag, or an irreversible data gap. This page is the reference for provisioning slots that stay stable under production churn.

Logical replication slot lifecycle and WAL retention A state diagram. pg_create_logical_replication_slot creates the slot in the RESERVED state, where active is false, restart_lsn is set, and WAL is retained but nothing is decoded yet. When a consumer connects the slot becomes ACTIVE: it streams and confirmed_flush_lsn advances so acknowledged WAL is released. If the consumer disconnects the slot becomes INACTIVE, where restart_lsn is frozen and retained WAL grows; a reconnect returns it to ACTIVE. From ACTIVE, pg_drop_replication_slot moves it to the terminal DROPPED state, discarding restart_lsn so the consumer must take a full re-snapshot. From INACTIVE, crossing max_slot_wal_keep_size moves it to the terminal INVALIDATED state where wal_status reads lost and the dead slot must be dropped and re-seeded. pg_create_logical_replication_slot() RESERVED active=false · restart_lsn set WAL retained, nothing decoded consumer connects ACTIVE consumer streaming confirmed_flush_lsn advances → WAL freed disconnects reconnects INACTIVE consumer disconnected restart_lsn frozen · WAL grows pg_drop_replication_slot() DROPPED restart_lsn discarded cursor gone → full re-snapshot crosses max_slot_wal_keep_size INVALIDATED wal_status = lost slot dead → drop + re-seed

Prerequisites & Configuration Objects

A slot cannot be created — or rather, cannot usefully retain and decode WAL — unless the server-level decoding surface is already in place. These parameters are static: changing wal_level requires a full restart, and max_replication_slots / max_wal_senders require a restart as well, so provision headroom before the first slot is ever needed rather than during an incident.

sql
-- Publisher server prerequisites (wal_level and the slot/sender ceilings need a restart).
ALTER SYSTEM SET wal_level = 'logical';
ALTER SYSTEM SET max_replication_slots = 20;   -- >= total logical + physical slots
ALTER SYSTEM SET max_wal_senders       = 24;   -- >= max_replication_slots
-- Cap the WAL a stalled slot can pin so one dead consumer cannot fill the disk (PG 13+).
ALTER SYSTEM SET max_slot_wal_keep_size = '20GB';
-- Optional but recommended: exposes xmin/commit timing for lag and conflict analysis.
ALTER SYSTEM SET track_commit_timestamp = 'on';
SELECT pg_reload_conf();   -- wal_level, max_replication_slots, max_wal_senders still need a restart.

wal_level = logical is non-negotiable and roughly doubles per-row WAL volume for UPDATE/DELETE, because the old-tuple identity is now logged — budget the extra pg_wal throughput before enabling it. The internal batching, spill-to-disk, and reorder-buffer behaviour that governs how the retained WAL is reassembled into transactions is documented in WAL stream mechanics; the operational takeaway is that a slot pins WAL from its restart_lsn forward, and a single long-running transaction on the primary holds that cursor back for every byte written since it began.

Slot creation requires the REPLICATION attribute (or superuser). Provision a dedicated least-privilege role rather than reusing an application account — the full privilege model, including the pg_hba.conf replication database entry a streaming consumer needs, is covered in security boundaries and permissions.

sql
-- Least-privilege replication role for the CDC consumer.
CREATE ROLE cdc_reader WITH LOGIN REPLICATION PASSWORD 'REDACTED';
GRANT USAGE ON SCHEMA public TO cdc_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO cdc_reader;  -- needed for the initial copy

Finally, choose the output plugin deliberately. The default pgoutput (built in since PostgreSQL 10) is required for native subscriptions and is the lowest-overhead choice for a Python consumer that speaks the logical replication protocol. wal2json and decoderbufs emit JSON/Protobuf and simplify hand-written parsers at the cost of a required extension and extra CPU per change. The plugin is bound at creation and cannot be altered — the trade-offs are compared in replication slot types.

Step-by-Step Implementation

The following sequence provisions a production slot idempotently, verifies its reserved state, and hands it to a consumer without losing changes. The exact parameter matrix for the creation function is broken down in the pg_create_logical_replication_slot step-by-step walkthrough.

1. Confirm prerequisites before touching anything. Fail fast if the server was never restarted after wal_level was set.

sql
SELECT name, setting FROM pg_settings
WHERE name IN ('wal_level', 'max_replication_slots', 'max_wal_senders', 'max_slot_wal_keep_size');
-- wal_level must read 'logical'; if it still reads 'replica', the restart has not happened.

2. Create the slot idempotently. Wrapping creation in a guard makes the step safe to re-run from a deployment pipeline and avoids the duplicate_object error on retries.

sql
DO $$
BEGIN
    IF NOT EXISTS (
        SELECT 1 FROM pg_replication_slots WHERE slot_name = 'etl_pipeline_slot'
    ) THEN
        PERFORM pg_create_logical_replication_slot(
            'etl_pipeline_slot',  -- slot name (unique per cluster)
            'pgoutput',           -- output plugin, bound for the slot's lifetime
            false,                -- temporary = false: survive the creating session
            true                  -- two_phase = true (PG 14+): decode prepared transactions
        );
    END IF;
END $$;

The temporary and two_phase arguments were added in PostgreSQL 14; on 13 and earlier the function takes only the name and plugin. A non-temporary slot persists across server restarts and creating-session disconnects — which is exactly what a durable CDC pipeline needs, and exactly why a forgotten one leaks WAL indefinitely.

3. Verify the slot is reserved. Immediately after creation the slot is active = false with a set restart_lsn; WAL is now being retained but nothing is being decoded until a consumer attaches. This gap is intentional for bootstrapping but must be short and monitored.

sql
SELECT slot_name, plugin, slot_type, active, restart_lsn, confirmed_flush_lsn
FROM pg_replication_slots WHERE slot_name = 'etl_pipeline_slot';

4. Attach the consumer. For a native path, CREATE SUBSCRIPTION ... WITH (create_slot = false, slot_name = 'etl_pipeline_slot') binds an existing slot so slot provisioning and subscription creation stay decoupled and independently recoverable. For a Python worker, open a LogicalReplicationConnection and begin streaming from the slot. The consumer must advance the cursor by sending standby status updates; without them PostgreSQL treats the consumer as alive but idle and never recycles WAL past restart_lsn.

python
import psycopg2
from psycopg2.extras import LogicalReplicationConnection

conn = psycopg2.connect(
    "host=primary dbname=app user=cdc_reader replication=database",
    connection_factory=LogicalReplicationConnection,
)
cur = conn.cursor()
cur.start_replication(
    slot_name="etl_pipeline_slot",
    decode=False,  # pgoutput is binary; parse it or use options for a text plugin
    options={"proto_version": "1", "publication_names": "etl_pub"},
)

def consume(msg):
    process(msg.payload)          # idempotent upsert keyed on primary key + LSN
    msg.cursor.send_feedback(flush_lsn=msg.data_start)  # advance confirmed_flush_lsn

cur.consume_stream(consume, keepalive_interval=10)  # feedback at least every 10 s

5. Confirm the cursor is advancing. Within a few seconds of the consumer connecting, active flips to true and confirmed_flush_lsn begins tracking restart_lsn forward. If confirmed_flush_lsn stays frozen while the primary keeps writing, the consumer is reading but never acknowledging — the single most common cause of runaway WAL growth. Durable-state handling and initial-snapshot coordination continue in the subscription sync procedures.

At scale, none of this should be executed by hand. The idempotent SQL above is designed to drop straight into a provisioning role — automating slot creation with Ansible shows how to make slot lifecycle a versioned, converging part of your infrastructure-as-code rather than a manual runbook step.

Parameter Reference Table

Parameter / argument Valid values Default Logical-replication behavior
wal_level replica, logical replica Must be logical to decode; restart required; raises UPDATE/DELETE WAL volume.
max_replication_slots integer ≥ 0 10 Hard ceiling on total slots; a slot cannot be created past it. Restart required.
max_wal_senders integer ≥ 0 10 Must be ≥ max_replication_slots; one sender per active streaming consumer.
max_slot_wal_keep_size size or -1 -1 (PG 13+) Caps WAL a slot may pin; past it the slot is invalidated, not paused. -1 = unbounded.
track_commit_timestamp on, off off Enables pg_last_committed_xact(); needed for commit-time lag and conflict analysis.
plugin (creation arg) pgoutput, wal2json, … n/a Bound for the slot’s lifetime; consumer must match. Cannot be altered after creation.
temporary (PG 14+) true, false false true drops the slot when the session ends — never for a durable pipeline.
two_phase (PG 14+) true, false false true decodes PREPARE/COMMIT PREPARED so 2PC transactions replicate.
failover (PG 17+) true, false false Synchronizes the slot to physical standbys so it survives a publisher failover.

PG 17 note: the failover slot option and pg_sync_replication_slots() close a long-standing gap where a publisher failover orphaned every logical slot and forced consumers to re-seed; on 16 and earlier, slot recovery after a primary switch is a manual runbook step.

Diagnostic Queries

Slot state, retained WAL, and lag in one pass. Run this first for any slot incident — a frozen restart_lsn with active = false is the WAL-bloat signature.

sql
SELECT slot_name, plugin, slot_type, active,
       restart_lsn, confirmed_flush_lsn,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal,
       wal_status,          -- PG 13+: reserved | extended | unreserved | lost
       safe_wal_size        -- PG 13+: bytes left before invalidation (NULL if unbounded)
FROM pg_replication_slots
ORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC;

Threshold callout: alert when retained_wal for any single slot exceeds ~25% of the pg_wal volume, or when wal_status reads extended (the slot has crossed max_slot_wal_keep_size and is on borrowed time before lost).

Consumer liveness and end-to-end lag from the sender’s perspective — join to see whether an active slot is actually keeping up.

sql
SELECT s.slot_name, s.active, r.application_name, r.state,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), r.sent_lsn))   AS send_lag,
       pg_size_pretty(pg_wal_lsn_diff(r.sent_lsn, r.flush_lsn))            AS flush_lag,
       (now() - r.reply_time)                                             AS since_last_reply
FROM pg_replication_slots s
LEFT JOIN pg_stat_replication r ON r.pid IS NOT DISTINCT FROM s.active_pid
WHERE s.slot_type = 'logical';

Threshold callout: since_last_reply climbing past your keepalive_interval (10 s in the example above) means the consumer has stopped sending feedback even though the connection is open — treat it as a stalled consumer, not a healthy idle one.

Blocking transaction check — because a slot cannot advance restart_lsn past the oldest running transaction the decoder still needs.

sql
SELECT pid, state, now() - xact_start AS xact_age, left(query, 80) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start ASC
LIMIT 5;   -- a very old xact_start pins WAL for every logical slot on the cluster.

Exporting these signals as time series with SLO alerting — rather than running them by hand during an incident — is the job of asynchronous monitoring integration.

Failure Modes & Gotchas

Orphaned slot exhausts the disk. Signature: pg_wal grows steadily; one slot shows a frozen restart_lsn and active = false. Root cause: a slot was created and its consumer never attached, or the consumer died without dropping the slot. Remediation: restart the consumer, or SELECT pg_drop_replication_slot('etl_pipeline_slot') if it is genuinely abandoned. Prevention: set max_slot_wal_keep_size, which trades a bounded re-seed for a full disk, and alert on active = false lasting more than a few minutes.

Slot invalidated after crossing max_slot_wal_keep_size. Signature: wal_status = 'lost', and the consumer’s next connection fails with requested WAL segment ... has already been removed. Root cause: retention exceeded the cap while the consumer was down, so PostgreSQL invalidated the slot to protect the disk (PG 13+). Remediation: the slot is dead — drop it, recreate it, and re-seed the consumer from a fresh snapshot. There is no way to rewind. Tune the cap against your worst tolerable consumer outage.

Consumer connected but WAL never recycles. Signature: active = true, yet confirmed_flush_lsn stays frozen and retained_wal climbs. Root cause: the consumer streams changes but never sends standby feedback (send_feedback / flush_lsn never called). Remediation: fix the feedback loop so it fires at least every keepalive_interval; confirm with diagnostic query 1 that confirmed_flush_lsn starts moving.

Wrong plugin bound at creation. Signature: the consumer errors on the very first message, or a native subscription refuses to attach a wal2json slot. Root cause: the plugin is fixed for the slot’s lifetime and does not match the consumer’s decoder. Remediation: drop and recreate the slot with the correct plugin — pgoutput for native subscriptions and protocol-speaking Python workers, a text/JSON plugin only for parsers built around it.

Slot lost after publisher failover (PG 16 and earlier). Signature: after promoting a standby, no logical slots exist and every consumer requests WAL the new primary never retained. Root cause: logical slots were not synchronized to standbys before PG 17. Remediation: on 17, create slots with failover = true and run pg_sync_replication_slots(); on 16 and earlier, script slot recreation plus a re-snapshot into the promotion runbook as a mandatory step.

Integration Touchpoints

A slot is one half of a contract whose other half lives across several adjacent topics. Upstream, the publication it is bound to by a subscriber determines which decoded changes actually flow through it, and the topology reasoning behind that binding — fan-out, cascading, and partial datasets — sits in the logical replication architecture fundamentals. Downstream, the cursor this slot maintains is the resume point that subscription sync procedures build the initial snapshot against, and the LSN it confirms is the number asynchronous monitoring integration exports for lag SLOs.

When the consumer is an event-streaming pipeline rather than a native subscriber, the same slot is opened by the Debezium connector, and its decoded changes are fanned out through the Kafka event routing integration — at which point offset management and idempotency move out of the SQL apply worker and into the streaming layer, while the slot on the publisher keeps enforcing the same WAL-retention contract described here.

Frequently Asked Questions

Should I let CREATE SUBSCRIPTION create the slot, or pre-create it?

Pre-create it for any production pipeline. Decoupling slot provisioning from subscription creation lets you version the slot in infrastructure-as-code, recover the subscription without disturbing the cursor, and attach a non-native consumer (Debezium or a Python worker) to the exact same slot. Use CREATE SUBSCRIPTION ... WITH (create_slot = false, slot_name = ...) to bind it.

Is it ever safe to drop a slot to free WAL during an incident?

Only after you have confirmed the consumer has permanently failed and you accept a full re-snapshot. Dropping a slot discards restart_lsn, so the consumer can no longer resume — it must re-seed from a new snapshot, and any change written between the drop and the re-seed that the consumer had not yet confirmed is gone. Prefer max_slot_wal_keep_size so PostgreSQL makes that trade-off deliberately rather than you doing it under pressure.

What is the difference between restart_lsn and confirmed_flush_lsn?

restart_lsn is the oldest WAL the slot still needs and therefore the retention boundary; confirmed_flush_lsn is the point the consumer has acknowledged as durably processed. WAL is recyclable up to restart_lsn, which trails confirmed_flush_lsn by at most the oldest in-progress transaction. If confirmed_flush_lsn advances but restart_lsn does not, a long-running transaction is holding the cursor back.

Do temporary slots have any production use?

Rarely. A temporary = true slot is dropped automatically when its creating session ends, which is useful for one-off ad-hoc decoding or a short-lived diagnostic stream, but fatal for a durable pipeline — a consumer reconnect creates a new session and the old slot (with its cursor) is already gone. Durable CDC always uses non-temporary slots.