PostgreSQL Replication Slots: The Forgotten Slot That Fills the Disk, Capping WAL, and the xmin That Stops Vacuum
A replication slot whose consumer is gone fills the primary's disk with WAL and stalls autovacuum. How to choose between slots, wal_keep_size and a WAL archive, how to size max_slot_wal_keep_size, what to alert on in pg_replication_slots, and the pitfalls waiting in CDC and failover.
A replication slot is a promise PostgreSQL makes to a consumer: "I will not remove these WAL records until you have read them." The promise is useful. It lets a replica or a change data capture (CDC) tool pick up exactly where it left off after a short outage. The problem is that the promise outlives the consumer. A replica that was shut down, a CDC connector that was deleted, or a slot left behind by a one-off experiment keeps pinning WAL on the primary's disk. When that disk fills, writes stop, and the outage usually starts with an alert that looks unrelated.
What a slot holds back, and when
Every slot has a restart_lsn. The server cannot recycle WAL after that position. While the consumer is connected and reading, the position moves forward and retained WAL stays small. When the consumer goes away, the position freezes and every byte of WAL produced from then on stays on disk. On a write-heavy database that can be tens of gigabytes per hour.
Slots do not only retain WAL. They can also stop cleanup:
- Logical slots hold a
catalog_xmin. Dead rows in the system catalogs cannot be removed, so workloads with frequent DDL or heavy temporary table use bloat the catalogs. - A physical slot combined with
hot_standby_feedback = onstores the oldestxminthe replica reported. When the replica disconnects, the slot keeps the last reported value. Autovacuum on the primary then cannot remove dead rows in any table.
So an abandoned slot hurts you two ways: the disk fills and the tables bloat. The second one is nastier, because it shows up as "queries got slow" rather than as a storage alert.
Slot, wal_keep_size, or archive
There are three ways to let a lagging replica catch up, and the difference is which risk you accept.
wal_keep_size always keeps a fixed amount of WAL. It never fills the disk by surprise. In exchange, a replica that falls further behind than that amount cannot catch up and has to be rebuilt.
A replication slot makes the opposite trade: nothing is lost no matter how far the consumer falls behind, and the disk can grow without limit.
A WAL archive (archive_command or a backup tool on the primary, restore_command on the replica) ships WAL off the primary into object storage. A replica that drops off streaming replays from the archive. The primary's disk risk goes away; you pay for storage and for monitoring the archive itself.
For physical replicas my recommendation is: if you already archive WAL for point in time recovery, use slots, but always cap them with max_slot_wal_keep_size. The archive becomes the recovery path for a replica that exceeds the cap. If you have no archive, set one up first. Leaving an unbounded slot on a production primary means valuing one replica's health above the primary's, and that is almost never the right priority.
For logical replication and CDC you have no choice: logical decoding requires a slot. The work there is all monitoring and capping.
Setting a cap
max_slot_wal_keep_size, added in PostgreSQL 13, caps how much WAL any slot may retain. The default is -1, meaning unlimited.
# postgresql.conf
max_slot_wal_keep_size = 50GB
When the cap is exceeded the slot is invalidated and its consumer can no longer resume from it. A physical replica that can read from the archive recovers on its own; otherwise it is rebuilt. For a logical consumer it means taking the initial snapshot again. That is painful, but it beats the primary stopping. Size the cap from the free space on the volume holding the WAL directory and from how much WAL you generate during the longest consumer outage you consider reasonable.
PostgreSQL 18 added idle_replication_slot_timeout, which invalidates slots that have not been used for a given time. It is disabled by default. It complements the size cap: on a quiet database an abandoned slot may never reach the size limit, yet it holds back xmin and blocks cleanup the whole time.
Monitoring
pg_replication_slots gives you everything you need. Run this on the primary:
SELECT slot_name,
slot_type,
active,
wal_status,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal,
pg_size_pretty(safe_wal_size) AS headroom,
xmin,
catalog_xmin
FROM pg_replication_slots
ORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC NULLS LAST;
wal_status can be reserved, extended, unreserved or lost. unreserved means the slot may be lost at the next checkpoint; lost means it is no longer usable. safe_wal_size shows how much room is left before the cap, and is null when max_slot_wal_keep_size is unlimited.
Two alert rules cover most cases. First, alert when a slot has been inactive longer than a set time, a little longer than the consumer's known maintenance windows; on PostgreSQL 17 and later the inactive_since column makes this a direct query. Second, alert when retained WAL exceeds a fraction of the free space on the WAL volume. A ratio keeps the alert meaningful after the disk is resized, which a fixed gigabyte threshold does not.
Watch slots that block cleanup separately. Measure the age of xmin and catalog_xmin with age(); if that number keeps growing, the slot is holding vacuum back.
Pitfalls
Permanent slots nobody remembers. pg_basebackup uses a temporary slot while streaming by default, and the slot disappears when it finishes. If you create a permanent one with -C -S name and then never finish setting up the replica, the slot stays. Make checking the slot list part of every replica build.
CDC on a quiet database. A logical slot only advances when the consumer confirms progress. If the tables being captured do not change, some CDC tools never confirm, while other databases on the same server keep producing WAL. The slot retains WAL without ever decoding a relevant change. The fix is a heartbeat table the consumer writes to periodically; tools such as Debezium have built-in settings for this.
Logical slots lost on failover. Before PostgreSQL 17, logical slots are not copied to replicas. When the primary fails and a replica is promoted, the CDC consumer has no slot to resume from and changes in the gap are lost. From 17 on you can create the slot with the failover option and enable sync_replication_slots on the standby to keep it synchronized. On older versions, assume your failover runbook includes a fresh CDC snapshot.
Dropping an active slot. pg_drop_replication_slot() refuses to drop an active slot. You first terminate the connection in active_pid with pg_terminate_backend(). If the consumer reconnects automatically it will grab the slot again in between, so stop the consumer first.
Setting the cap and forgetting it. With max_slot_wal_keep_size in place the disk no longer fills, but a consumer can silently lose its slot. Treat wal_status = 'lost' as an alert too. The cap moves the failure somewhere else; it should not make it invisible.
When not to use a slot
Do not open permanent slots for short lived copies, test replicas or one-time data migrations. Those jobs can tolerate falling behind and can be restarted; wal_keep_size or the archive is enough. And if nobody on the team has an alert on slot state, do not leave a slot unbounded. A slot is a safe promise only while someone is watching it.