Engineering research / databases

Replication Slots That Fill the Disk: WAL Retention, max_slot_wal_keep_size, Lost Slots and the Idle Timeout

Why an abandoned PostgreSQL replication slot keeps every WAL file and can fill the disk or block vacuum, how to read pg_replication_slots, how max_slot_wal_keep_size and the PostgreSQL 18 idle_replication_slot_timeout bound the damage, and a safe runbook for dropping or recovering a slot.

By Sandeep Belgavi · Published 2026-10-26

Core engineering · Practical technical guide · 26 October 2026

Why an abandoned PostgreSQL replication slot keeps every WAL file and can fill the disk or block vacuum, how to read pg_replication_slots, how max_slot_wal_keep_size and the PostgreSQL 18 idle_replication_slot_timeout bound the damage, and a safe runbook for dropping or recovering a slot.

What a replication slot promises

A replication slot is the primary's promise to a consumer: I will keep every piece of WAL you have not confirmed yet. A physical slot serves a streaming standby; a logical slot serves logical decoding, such as a CDC connector or a logical replication subscription. Without a slot, a standby that falls behind can find that the WAL it needs was recycled, and must be rebuilt from a new base backup. With a slot, the primary simply keeps the WAL.

The promise has no end date by default. The logical decoding documentation is direct about it: slots persist across crashes, know nothing about the state of their consumers, and prevent removal of required resources even when no connection is using them. That includes WAL and, for logical slots, catalog rows that VACUUM would otherwise remove; in extreme cases the database shuts down to prevent transaction ID wraparound. A CDC pipeline that is paused for a weekend, a decommissioned standby whose slot was never dropped, or a connector stuck on one bad event all leave a slot that keeps WAL indefinitely.

Reading pg_replication_slots

ColumnWhat it tells you
slot_type, active, active_pidPhysical or logical, and whether a consumer is connected right now
restart_lsnThe oldest WAL position the slot still needs; WAL from here on cannot be removed
confirmed_flush_lsnFor logical slots, how far the consumer has confirmed
wal_statusreserved, extended, unreserved or lost
safe_wal_sizeBytes of WAL that can still be written before the slot is in danger of becoming lost; NULL when there is no limit
inactive_sinceWhen the slot became inactive; NULL while it is streaming
invalidation_reasonWhy a slot was invalidated: wal_removed, rows_removed, wal_level_insufficient or idle_timeout

wal_status is the column to alert on. reserved means the claimed files are within max_wal_size. extended means max_wal_size is exceeded but the files are still retained, by the slot or by wal_keep_size. unreserved means the slot no longer retains all the files it needs and some will be removed at the next checkpoint. lost means the slot is no longer usable.

SQL for the primary; pg_wal_lsn_diff measures how much WAL each slot is holding back. inactive_since and invalidation_reason appear in the PostgreSQL 17 documentation but not in 16; drop them on 16 and earlier.

SELECT slot_name, slot_type, active, wal_status,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained,
       pg_size_pretty(safe_wal_size) AS headroom,
       inactive_since, invalidation_reason
FROM pg_replication_slots
ORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC;

Two settings that bound the damage

max_slot_wal_keep_size caps the WAL that slots may retain in pg_wal at checkpoint time. The default, -1, means unlimited. With a limit set, a slot whose restart_lsn falls further behind than the limit loses the files it needs, its wal_status goes through unreserved to lost, and the standby or subscriber using it can no longer continue. That is a deliberate trade: you give up one consumer to protect the primary's disk.

PostgreSQL 18 added idle_replication_slot_timeout. A slot that has been inactive, not used by a replication connection, for longer than the setting is invalidated with invalidation_reason idle_timeout. The default is 0, which disables it. Invalidation happens at a checkpoint, so it can lag the timeout by up to checkpoint_timeout unless you force a checkpoint, inactivity is measured from inactive_since, and the setting does not apply to slots that reserve no WAL or to synced slots on a standby. max_slot_wal_keep_size has existed for several major versions, but idle_replication_slot_timeout does not appear in the PostgreSQL 17 documentation, so check your version before relying on it.

A worked example: a paused CDC connector

A primary writes about 2 GB of WAL an hour, has 200 GB free on the WAL volume, and runs a logical slot for a CDC connector. The connector is paused on Friday evening for a schema change and forgotten. The slot becomes inactive, its restart_lsn stops moving, and the primary keeps every WAL file from that point. At 2 GB an hour the volume fills in about 100 hours, early on Tuesday, and when pg_wal has no space the server can no longer write WAL. Meanwhile the slot's catalog_xmin also holds back vacuum of system catalog rows.

With max_slot_wal_keep_size set to 100 GB, the slot would be marked lost after about 50 hours of retention, the disk would stop growing, and the connector would need a fresh snapshot when it returns. With idle_replication_slot_timeout set to 24 hours, the slot would be invalidated on Saturday evening at the first checkpoint after the timeout. Either outcome is better than an outage, and both mean the consumer must re-initialise, so pick limits that are longer than any planned pause. Record the planned pauses as well: a maintenance ticket that says the connector will be stopped for three days is the input that tells you whether a 100 GB cap or a 24-hour idle timeout is safe for that slot.

Runbook: retained WAL is growing

  1. Find the slot holding the most WAL with the query above, and check active, active_pid and inactive_since.
  2. If the consumer is alive but slow, fix the consumer first: a connector stuck on one event, a standby with a full disk, a network partition.
  3. If the consumer is gone for good, drop the slot with pg_drop_replication_slot. WAL is released at the next checkpoints.
  4. If the consumer will return but the disk will not last, decide explicitly to sacrifice it: set or lower max_slot_wal_keep_size and reload, and plan the re-snapshot.
  5. Afterwards, add alerts on wal_status other than reserved, on retained WAL per slot, and on inactive_since older than your longest planned pause.

Failure modes

  • wal_keep_size is not a cap. It sets a minimum of extra WAL to keep for standbys; it never limits what slots retain.
  • Archiving masks the problem. If archive_command is failing, WAL also accumulates; check pg_stat_archiver before blaming a slot.
  • Slots on a promoted standby. Logical slots synced to a standby carry over after failover; slots created by hand on the old primary do not, and a stale slot can be left on whichever node outlives its purpose.
  • hot_standby_feedback without a slot. The documentation notes it protects rows only while the standby is connected; with a slot, the protection persists, and so does the bloat if the standby disappears.

Verification and when this is unnecessary

In a test cluster, create a slot with no consumer, generate WAL, and watch retained WAL grow and wal_status change; then set max_slot_wal_keep_size low and confirm the slot becomes lost and the disk stops growing. If you run no standbys and no logical decoding, you have no slots and none of this applies; a quick SELECT count(*) FROM pg_replication_slots on every primary is the cheapest audit you can run today.

Sources and scope

Checked against these sources on 26 October 2026. Evergreen guide based on the PostgreSQL 18 documentation; the CDC example uses illustrative arithmetic, not a measurement.

Alert on wal_status and on the WAL each slot retains, drop slots whose consumers are gone, set max_slot_wal_keep_size to protect the disk, consider idle_replication_slot_timeout on PostgreSQL 18, and choose limits longer than any planned pause because a lost slot means a re-snapshot.