Log-based CDC reads Postgres through a replication slot, and a slot keeps every WAL file its consumer hasn't confirmed yet. That's what makes CDC safe: stop the stream on Friday, start it on Monday, and nothing is missed. It's also how CDC fills a disk. If the consumer stops, the WAL keeps piling up, and when the disk that holds pg_wal fills, Postgres can panic and shut down.
This guide covers why it happens, the query that shows which slot is holding WAL, and the settings that put a ceiling on it. It applies to any tool that reads the Postgres WAL: Debezium, Fivetran, Airbyte, AWS DMS, or Bruin Cloud Streams, which we build.
In short
- A slot holds WAL until its consumer confirms it. A stopped, slow or forgotten consumer means a growing
pg_wal. - One query shows it. Compare each slot's
restart_lsnwith the current WAL position, and alert on the result. - Drop slots nobody uses. Besides WAL, an inactive slot also stops vacuum from cleaning up catalog rows.
- Cap it if production comes first.
max_slot_wal_keep_size(Postgres 13+) andidle_replication_slot_timeout(Postgres 18) protect the disk. The price is a full re-sync for the slot they invalidate. - Quiet databases need a heartbeat. A slot can only move forward when something changes in its own database.
Why a slot keeps WAL
Every logical replication slot remembers two positions in the WAL:
restart_lsn: the oldest WAL the consumer might still need. Postgres won't remove WAL from that point on at a checkpoint.confirmed_flush_lsn: how far the consumer has confirmed receiving changes.
The slot knows nothing about who reads it. It keeps that WAL whether or not anything is connected, and it also stops vacuum from removing the catalog rows it needs. Leave an abandoned slot long enough and, in extreme cases, Postgres has to shut down to prevent transaction ID wraparound. The Postgres docs are blunt about it: if a slot is no longer needed, drop it.
The usual ways it goes wrong:
- The CDC pipeline stopped, failed or was paused, and nobody noticed.
- A test connector was deleted but its slot wasn't. AWS DMS, for example, leaves slots open when a task stops; it only drops a slot it created itself, when you delete the task.
- The consumer is connected but can't keep up with the write load.
- The database is quiet, so the consumer has nothing to confirm (more on that below).
See which slot is holding WAL
Run this on the primary (pg_current_wal_lsn() doesn't work on a standby):
select slot_name,
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,
inactive_since -- Postgres 17+
from pg_replication_slots
order by pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) desc nulls last;
What the columns tell you:
| Column | What to look for |
|---|---|
active | false means nothing is reading the slot right now |
retained_wal | How much WAL this slot is holding on disk |
wal_status | reserved is normal. extended means the slot is keeping WAL beyond max_wal_size. unreserved means some of it will be removed at the next checkpoint. lost means the slot can no longer be used |
safe_wal_size | How much more WAL can be written before the slot is lost. Empty when there's no cap |
inactive_since | When the slot stopped streaming (Postgres 17+) |
To see all the WAL on disk, slots or not:
select pg_size_pretty(sum(size)) as wal_on_disk from pg_ls_waldir();
Alert on it
A query you run by hand finds the problem after it happened. Put an alert on it. On managed Postgres, these are the metrics to watch:
| Service | Metrics to alert on |
|---|---|
| AWS RDS | OldestReplicationSlotLag (WAL the most lagging slot hasn't consumed), TransactionLogsDiskUsage, FreeStorageSpace |
| Google Cloud SQL | Disk bytes used by data type (the Wal and Replication_slot types); there's no per-slot lag metric |
| Azure flexible server | Max Logical Replication Lag (on by default), Transaction Log Storage Used, Storage percent |
On RDS, OldestReplicationSlotLag and TransactionLogsDiskUsage are reported for the whole instance, not per slot, so use the query above to find which slot is the problem. ReplicationSlotDiskUsage is a different thing: it counts decoding files that spill to disk, not retained WAL.
Azure acts on its own when storage runs low: past a storage threshold it drops inactive logical replication slots, and at 95% storage (or under 5 GiB free) the server turns read-only. Both are worse surprises than an alert.
Drop slots nobody uses
Once you've confirmed with whoever owns a slot that nothing needs it:
select pg_drop_replication_slot(slot_name)
from pg_replication_slots
where slot_name = 'old_test_slot'
and not active;
pg_drop_replication_slot raises an error on an active slot, and the not active filter keeps the query from touching one. Dropping frees the WAL straight away. The catch: a CDC tool that still expected that slot can't resume, and has to take a fresh snapshot.
Watch for slots left behind by config changes too. With ingestr, for example, changing which tables a Postgres stream replicates starts a new connector with its own slot, and the old slot keeps holding WAL until you drop it.
Put a ceiling on it
Two settings stop a slot from taking the whole disk. Both trade disk safety for a re-sync.
max_slot_wal_keep_size (Postgres 13+) caps how much WAL any slot can hold. The default is -1, no cap. It's checked at each checkpoint and takes effect on reload, without a restart. When a slot passes the limit, its wal_status goes to unreserved and then lost, Postgres removes the WAL, and the consumer can't resume; on Postgres 17+ the slot's invalidation_reason reads wal_removed. On self-hosted Postgres:
alter system set max_slot_wal_keep_size = '100GB';
select pg_reload_conf();
idle_replication_slot_timeout (Postgres 18) invalidates slots that have been inactive for longer than the timeout. The default is 0, off. It's also applied at checkpoints, and invalidated slots show idle_timeout as the reason.
Pick limits well above your normal lag, so only a truly stuck slot hits them. On managed services:
| Service | max_slot_wal_keep_size | idle_replication_slot_timeout |
|---|---|---|
| AWS RDS | Parameter group, default -1 | Postgres 18, no reboot needed |
| Google Cloud SQL | Flag from 100 GB to 10 TB, no restart | Not supported |
| Azure flexible server | Fixed at -1, can't be capped | Dynamic parameter, default 0 |
| Supabase | Through the CLI, no restart | Not documented |
One setting that sounds related but isn't: wal_keep_size is a minimum amount of WAL to keep for standbys, not a cap on slots.
Quiet databases need a heartbeat
Each slot follows one database. If nothing changes in that database, the consumer has nothing to confirm, so the slot can't move forward while WAL from the rest of the server keeps piling up.
On RDS this bites even on a quiet server: RDS switches to a new WAL segment every 5 minutes even with no user transactions, and RDS segments are 64 MB. If every switch closes a full segment, that's about 18 GB a day held by one stuck slot.
The fix is a small, regular write in the slot's database, so the consumer always has something to confirm. Either update a one-row heartbeat table every few minutes, or write a logical decoding message:
select pg_logical_emit_message(false, 'heartbeat', now()::text);
Whether the slot moves forward depends on your CDC tool picking the write up, so check its docs. Debezium has heartbeat.interval.ms and heartbeat.action.query, and AWS DMS has a HeartbeatEnable setting for exactly this.
How Bruin Cloud Streams handles it
Bruin Cloud Streams runs on ingestr, which confirms a position in the WAL only after the changes are written and merged in the warehouse. That's what lets a stream resume without losing changes, and it's also why a stalled stream holds WAL. So the parts above are built in:
- Lag you can see. Bruin Cloud tracks how far behind each stream is, in WAL bytes for Postgres, and alerts you when it falls behind. Underneath, ingestr reports each slot's retained WAL, its remaining WAL budget and its
wal_status. - Loud failures. If a slot was invalidated and the saved position is gone, the stream fails instead of quietly copying half a table, and a full refresh rebuilds the table while the stream keeps running.
If you run ingestr yourself, the same numbers are on its Prometheus endpoint (--metrics-addr) as ingestr_replication_slot_retained_wal_bytes, ingestr_replication_slot_safe_wal_bytes and ingestr_replication_slot_wal_status. For the setup side, see how to enable Postgres logical replication for CDC.
Checklist
- Run the slot query and drop slots nobody owns.
- Alert on retained WAL or free storage, per the table above.
- Set
max_slot_wal_keep_sizeif a full disk would hurt more than a re-sync. - On Postgres 18, consider
idle_replication_slot_timeoutfor forgotten slots. - Add a heartbeat for databases that can go quiet.
FAQ
Why does CDC make my Postgres WAL grow?
Log-based CDC reads Postgres through a replication slot, and a slot keeps every WAL file its consumer hasn't confirmed yet. If the CDC tool stops, falls behind, or was deleted without dropping its slot, WAL keeps piling up in pg_wal until someone drops the slot or the disk fills.
How do I find which replication slot is holding WAL?
Query pg_replication_slots on the primary and compare each slot's restart_lsn with the current WAL position: pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) gives the bytes each slot is holding. Inactive slots with a large number are the usual culprits.
Is it safe to drop a Postgres replication slot?
Only if nothing needs it. Dropping a slot frees its WAL straight away, but the CDC tool that used it can't resume and has to take a fresh snapshot. pg_drop_replication_slot fails on an active slot, so check the active column and the slot's owner first.
What does max_slot_wal_keep_size do?
It caps how much WAL a replication slot can hold, from PostgreSQL 13. The default, -1, means no cap. When a slot passes the limit, Postgres removes the WAL anyway and the slot becomes unusable, so the CDC tool has to re-sync. It protects the disk at the cost of a re-snapshot.
What is idle_replication_slot_timeout?
A PostgreSQL 18 setting that invalidates replication slots that have been inactive for longer than the timeout, at the next checkpoint. The default is 0, which turns it off. Invalidated slots show idle_timeout as the invalidation reason.
