How a replication slot retains WAL and why stale slots cause bloat
A logical replication slot is a server-side record of how far a specific consumer has read the change stream. It exists so a subscriber or change-data-capture process can disconnect, restart, or crash and still resume exactly where it left off without missing a change. The guarantee is durable. The primary will not recycle any WAL segment at or after the slot's restart_lsn, because decoding may need to restart from that point.
WAL stream (oldest -> newest)
|----|----|----|----|----|----|----|----|----|
^ ^ ^
restart_lsn confirmed_flush_lsn current LSN
|<========= retained, cannot be recycled ====>|
confirmed_flush_lsn is what the consumer has acknowledged. restart_lsn can trail it, because decoding must start before any transaction that was still open. Everything from restart_lsn to the current write position stays on the primary's disk. If the consumer stops while writes continue, that range grows with every write and nothing on the server shrinks it.
Compare a streaming replica that runs without a slot. The primary recycles WAL on its normal schedule. A replica that falls too far behind eventually requests a segment that no longer exists, errors out, and has to be re-seeded. That is painful, but the replica's lag never fills the primary's disk.
Logical slots also carry a second cost. A slot's catalog_xmin stops vacuum from removing dead rows in the system catalogs, because the decoder may still need old catalog versions. A stale slot therefore causes catalog bloat and holds back transaction ID freezing, which adds wraparound pressure.
Treat every slot as an open-ended claim on primary disk. Each one needs monitoring, a cap, and a named owner.
PRIMARY
+--------------------------------------------------------------------+
| WAL stream (oldest -> newest) new writes |
| | |
| recycled v |
| +----+----+----+----+----+----+----+----+----+----+----+ |
| | | |WAL |WAL |WAL |WAL |WAL |WAL |WAL |WAL |WAL | |
| +----+----+----+----+----+----+----+----+----+----+----+ |
| ^ ^ ^ |
| | | | |
| restart_lsn confirmed_flush_lsn current LSN |
| |<====== retained, cannot be recycled ======>| |
| | |
| +---------+------------+ +------------------------------+ |
| | logical slot | | system catalogs | |
| | catalog_xmin -------+------>| dead rows kept: vacuum | |
| | restart_lsn | | cannot pass catalog_xmin | |
| | confirmed_flush_lsn | | -> catalog bloat and | |
| | plugin, database | | xid wraparound pressure | |
| +---------+------------+ +------------------------------+ |
| | |
+------------+-------------------------------------------------------+
^ acks advance the slot
|
+------------+--------------------+
| consumer: subscriber or CDC |
| if it stops, the slot never |
| advances and retained WAL grows |
+---------------------------------+
A logical slot pins every WAL segment from its restart_lsn to the current LSN, and its catalog_xmin blocks catalog vacuum, so a stopped consumer grows both costs while writes continue.
Inspecting slots with pg_replication_slots
The pg_replication_slots view shows what each slot is holding back. Read these columns first:
slot_name,plugin,slot_type,databaseidentify the slot and its consumer. Logical slots name a decoding plugin and a database. Physical slots have neither.activeshows whether a client is currently connected to the slot.restart_lsnis the oldest WAL position the slot still needs. WAL from this point forward cannot be removed.confirmed_flush_lsnapplies to logical slots only. It is the last position the consumer has acknowledged.catalog_xminis the oldest transaction whose catalog rows the slot keeps alive. This is the source of the catalog bloat described above.wal_status,safe_wal_size: some server versions include these columns. They report whether retained WAL is within limits and how much headroom remains before the slot is invalidated. Check your version's documentation for the exact values.
An inactive slot (active = false) has no consumer, so nothing is reading the WAL it retains. An active slot can still be a problem if it is connected but falling behind. In that case active is true, but the retained WAL keeps growing. You can only tell these two apart by trending the numbers over time.
For a scheduled monitoring job, compute two values per slot:
- Retained WAL:
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)). - Consumer lag (logical slots only): the same calculation using
confirmed_flush_lsn.
Store the raw byte values for alerting and keep the pretty-printed values for humans.
| State | Column signals | Operational meaning |
|---|---|---|
| Inactive and idle | active = false, retained WAL rising |
Consumer is gone or stopped. Find the owner. |
| Active but lagging | active = true, lag growing |
Consumer is too slow or stuck. Investigate throughput. |
| Healthy | active = true, lag small and stable |
Normal operation. |
| Invalidated | wal_status = 'lost' |
Required WAL is gone. The slot cannot resume and must be dropped and recreated. |
-- Per-slot WAL retention and consumer lag, for a scheduled monitoring job.
-- Run on the primary: pg_current_wal_lsn() errors during recovery (on a standby).
-- wal_status and safe_wal_size do not exist on older servers. Check your
-- version's documentation and remove those two columns if they are missing.
SELECT
slot_name,
slot_type,
plugin,
database,
active,
wal_status,
-- Raw bytes for alert thresholds. NULL if restart_lsn is NULL,
-- for example on an invalidated slot.
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS retained_wal_bytes,
-- Human-readable form for dashboards and runbooks.
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal,
-- Logical slots only. confirmed_flush_lsn is NULL for physical slots,
-- so this column is NULL for them too.
pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) AS consumer_lag_bytes,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)) AS consumer_lag,
-- Headroom before max_slot_wal_keep_size invalidates the slot.
-- NULL when no limit is configured.
pg_size_pretty(safe_wal_size) AS safe_wal_headroom,
catalog_xmin
FROM pg_replication_slots
ORDER BY retained_wal_bytes DESC NULLS LAST;
-- How to read the results over successive runs:
-- active = false and retained_wal_bytes rising -> inactive and idle; find the owner
-- active = true and consumer_lag_bytes rising -> connected but falling behind
-- active = true and lag flat and small -> healthy
-- wal_status = 'lost' -> invalidated; the slot cannot be used again
A monitoring query that reports retained WAL and logical consumer lag per slot, both in raw bytes for alerting and pretty-printed for humans, with notes on reading the results for each operational state.
Capping retained WAL with max_slot_wal_keep_size
By default max_slot_wal_keep_size is -1, meaning no limit. A slot can hold back WAL removal indefinitely, so one abandoned consumer can fill the WAL volume and stop writes on the primary. Setting a cap changes which component fails. At checkpoint time, if a slot would need more WAL than the cap allows, PostgreSQL removes the WAL anyway and invalidates the slot. The slot first reports wal_status = 'unreserved' and then 'lost'. The primary keeps running, and the consumer must resync.
Set the cap in postgresql.conf (for example, max_slot_wal_keep_size = 40GB) and reload the configuration. Then confirm the running value with SHOW max_slot_wal_keep_size. Once the cap is set, the safe_wal_size column in pg_replication_slots shows how much more WAL each slot can fall behind before it is invalidated.
Illustrative estimate. These inputs are assumed, not measured:
- WAL generation rate: 4 GB/hour
- Tolerated consumer outage: 6 hours, which gives 24 GB
- Bulk-load headroom: 16 GB
- Resulting cap: 40 GB
Check that 40 GB fits within the free space on the WAL volume and still leaves margin for normal WAL and checkpoint spikes.
Use a cap when protecting the primary matters more than keeping every consumer attached. Loosen it or avoid it when a full resync is unacceptable. That includes CDC pipelines that promise no gaps and tables so large that a re-snapshot would take days.
Sizing errors fail in both directions:
- Too small: slots are invalidated during routine deploys, network blips, or bulk loads.
- Too large: the volume can still fill before the cap is reached.
WAL rate changes as the workload changes, so recompute the cap on a regular schedule rather than setting it once.
PostgreSQL versions differ in whether they can automatically invalidate slots that have sat idle for a configured time. Check your version's documentation to see which of these options it supports.
-- Set the cap. ALTER SYSTEM writes postgresql.auto.conf, which overrides
-- postgresql.conf. Alternatively, edit postgresql.conf directly with:
-- max_slot_wal_keep_size = 40GB
-- Superuser required. 40GB is the illustrative estimate:
-- 4 GB/h x 6 h outage + 16 GB bulk-load headroom.
ALTER SYSTEM SET max_slot_wal_keep_size = '40GB';
-- This setting takes effect on reload. No restart is needed.
SELECT pg_reload_conf();
-- Confirm the running value. The default -1 means no limit.
SHOW max_slot_wal_keep_size;
-- Per-slot view: retained WAL, distance to invalidation, and status.
-- wal_status progresses: reserved -> extended -> unreserved -> lost.
-- safe_wal_size is NULL when no cap is set or the slot is already lost.
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 safe_wal_size
FROM pg_replication_slots
ORDER BY safe_wal_size NULLS FIRST;
Setting max_slot_wal_keep_size, reloading and confirming it, then checking each slot's retained WAL, safe_wal_size and wal_status to see how close it is to being invalidated as 'lost'.
Alerting thresholds that fire before the disk fills
Whichever invalidation behaviour you have, alerts are what give a human time to act. Use three classes, because each one covers a blind spot in the others.
Retained bytes per slot. Compute pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) for every slot. This catches consumers that are connected but slow, which an active = false check never flags. Expect noise during legitimate bulk loads, so set the threshold above your normal batch peaks.
Inactive beyond a window. Alert on any slot with active = false for longer than an agreed period. This catches abandoned slots on quiet databases, where retained bytes stay low until writes resume and the backlog grows quickly. A long window cuts noise from routine consumer restarts but delays detection.
WAL volume free space. Alert on both absolute free bytes and estimated time-to-full based on the recent growth rate. Percent-only alerts fire too late on fast-growing WAL. Illustrative estimate, with assumed inputs: an 85% alert on a 500 GB volume leaves 75 GB. At 30 GB/hour, that is about 2.5 hours. Putting WAL on its own volume keeps data files safe, but a full WAL volume still stops all writes.
| Signal | Catches | Misses | False-positive sources |
|---|---|---|---|
| Retained bytes per slot | Slow but connected consumers | Idle slots while writes are low | Bulk loads, index rebuilds |
| Inactive beyond window | Abandoned or orphaned slots | Connected consumers falling behind | Planned consumer restarts and deploys |
| Free bytes and time-to-full | Any WAL growth, including non-slot causes | Which slot is responsible | Short write bursts that skew the rate |
-- Run on the primary (pg_current_wal_lsn() errors on a standby).
-- Covers two of the three alert classes: retained bytes per slot and
-- inactive beyond a window. Alert on WAL volume free bytes and
-- time-to-full separately, from host-level metrics.
-- inactive_since requires PostgreSQL 17 or later. On older versions,
-- record when active first became false in your monitoring system.
-- Thresholds are placeholders: set retained bytes above normal bulk-load peaks.
WITH slots AS (
SELECT
slot_name,
slot_type,
active,
wal_status,
-- NULL restart_lsn means the slot holds no WAL (never reserved or already invalidated)
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS retained_bytes,
now() - inactive_since AS inactive_for
FROM pg_replication_slots
)
SELECT
slot_name,
slot_type,
active,
wal_status,
pg_size_pretty(retained_bytes) AS retained,
inactive_for,
-- Retained bytes catches connected-but-slow consumers.
retained_bytes > 50::numeric * 1024 * 1024 * 1024 AS alert_retained_bytes,
-- Inactivity catches abandoned slots on quiet databases, even with low retained bytes.
(NOT active AND inactive_for > interval '30 minutes') AS alert_inactive,
-- 'unreserved' or 'lost' means max_slot_wal_keep_size is already removing WAL this slot needs.
wal_status IN ('unreserved', 'lost') AS alert_wal_status
FROM slots
ORDER BY retained_bytes DESC NULLS LAST;
A single query for the primary that reports retained WAL and inactivity time per replication slot, so each slot can be checked against both the retained-bytes and inactive-window thresholds.
Incident remediation: catching up versus dropping the slot
Step 1: Identify the owner
Start with the slot name, plugin, and active_pid. For an active slot, match active_pid to pg_stat_activity to get the client address and application_name, then find the team or service behind it. Do not act on a slot until a person can say what reads from it and what breaks if it stops.
Step 2: Estimate whether it can catch up
Sample the slot's retained bytes twice, a few minutes apart. If the gap is shrinking, divide the remaining gap by the rate it is shrinking and compare the result with your time-to-full. If the gap is flat or growing, the consumer cannot catch up as things stand.
Step 3: Look for the real cause
A lagging slot is often a symptom of a problem somewhere else. Check these first:
- Long-running transactions on the primary. Decoding cannot move past an open transaction. A forgotten
idle in transactionsession can hold backrestart_lsnon every logical slot. - Very large transactions. A bulk update or backfill spills to disk during decoding. The consumer cannot confirm any of it until the transaction commits.
- Slow output plugins or consumers. Look for a plugin that is expensive per row, or a sink that is throttled or erroring.
Fixing any of these is usually cheaper than a resync.
Step 4: Terminate and drop, if you must
- Stop the consumer first. If you skip this, it may reconnect and reactivate the slot.
- Call
pg_terminate_backendon the slot'sactive_pid. - Call
pg_drop_replication_slot.
Dropping is irreversible. The downstream system stops at the last position it confirmed. It must be resynced from a fresh snapshot, because the changes in between are gone. If you drop a slot without knowing its owner, the warehouse, search index, or cache that read from it keeps serving data with silent gaps that nobody notices until the numbers disagree.
Failover and slot ownership hygiene
Slots and failover
Whether a logical slot survives promotion depends on your PostgreSQL major version and configuration. Historically, logical slots lived only on the primary. Newer releases can synchronize them to physical standbys, but only when that feature is explicitly configured. Test this in your own setup: promote a standby in staging and query pg_replication_slots on the new primary. Do not rely on a managed service's defaults or on what worked in a previous version. If the slot is missing, the consumer either fails to connect and needs a full resync, or someone recreates the slot at the current position and the changes in the gap are lost. A slot that was synced but has fallen behind can cause the same loss.
Ownership hygiene
Most stale slots are slots that nobody remembers creating. Put the owner and purpose into the name, for example cdc_billing_warehouse or sub_search_orders, so the person on call during an incident can tell who to page.
Keep an inventory that maps each slot to its consumer, owning team, and the host where that consumer runs. Every decommission runbook for a CDC connector, logical subscription, or migration tool should include a step to drop its slot. Stopping the process does not drop the slot, and the slot keeps retaining WAL after its consumer is gone.
Run an audit on a schedule. The query below selects from pg_replication_slots and returns every slot where active is false or slot_name does not match your naming pattern. Treat each row as a ticket: find the owner, or confirm there is none, before you drop the slot.
Alternatives, who needs slots, and the cost in engineering time
Slots are not the default answer; they are a trade. Use a logical slot when a consumer needs a gap-free change stream: CDC pipelines, logical subscriptions, or online migrations where a missed change means silent divergence. Avoid one when the consumer can re-snapshot cheaply, runs periodic batch extracts, or exists only for a short-lived task. Short-lived slots are the ones most likely to be forgotten.
| Approach | Data-loss guarantee | Primary disk risk | Resync cost if consumer falls behind | Operational effort |
|---|---|---|---|---|
| Uncapped logical slot | No gaps | Unbounded | None, until disk fills | High: inventory, alerts, owners |
Capped slot (max_slot_wal_keep_size) |
No gaps until cap, then slot invalidated | Bounded | Full resync after invalidation | Medium |
| Physical slot | No gaps for replica | Unbounded | None, until disk fills | Medium |
Replica with wal_keep_size or WAL archive |
Replica can fall off; archive covers gap if retained | Bounded | Rebuild replica if WAL is gone | Low to medium |
| Timestamp or batch extraction | Misses deletes and same-timestamp updates | None | Re-run extract | Low |
Illustrative estimate (assumed numbers, not a benchmark). Hours until disk full = free WAL disk ÷ WAL generation rate. Assume 60 GB free and 2 GB/hour: 60 ÷ 2 = 30 hours. A slot orphaned on Friday evening can fill the disk before Monday.
Monitoring, inventory, and runbooks cost a few days up front. An orphaned uncapped slot can cost an outage plus a full downstream resync. The conventions matter most during an incident. With a naming pattern that encodes the owner, a monitoring query you can paste and run, and a written catch-up-or-drop decision, the on-call engineer follows steps instead of guessing who owns a slot.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to replication slot wal bloat, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.