← Back to Home

Replication Slots and WAL: What I Measured on 18.6

PostgreSQLDatabaseDevOps

TL;DR: A replication slot is a promise. You tell the primary you will come back and read, so it holds WAL for you. If nobody reads, that promise has no ceiling.

Disclosure: this article recommends no product or service. If a promotional box appears at the bottom of the page, it is rendered by the site template and has nothing to do with the conclusions above.

How a slot nobody reads fills your disk

The problem a replication slot solves is simple. If a subscriber drops off, the primary must not delete WAL just because nobody is listening, or the subscriber comes back to a gap and has to rebuild from scratch. So the primary records a restart_lsn per slot — the oldest position that slot might still need — and checkpoints are not allowed to remove any segment newer than the oldest restart_lsn in the cluster.

That constraint is correct. The trouble is that by default it has no boundary.

Here is the environment I tested on, reproducible end to end: the official PostgreSQL 18.6 Windows binaries from EDB, one initdb instance, wal_level set to logical, one logical slot created and then deliberately never read.

initdb.exe -D pgdata -U postgres --auth=trust -E UTF8 --locale=C
psql -c "ALTER SYSTEM SET wal_level = 'logical';"
pg_ctl -D pgdata restart
psql -d pub_demo -c "SELECT * FROM pg_create_logical_replication_slot('stale_sub','pgoutput');"

Test 1: with the default -1, WAL grows and checkpoints do nothing

Start by printing the shipped defaults. This is where the whole story begins:

SELECT name, setting, unit, boot_val FROM pg_settings
 WHERE name IN ('max_slot_wal_keep_size','wal_level','logical_decoding_work_mem');

What I got (boot_val is the value compiled into the binary):

namesettingunitboot_val
max_slot_wal_keep_size-1MB-1
logical_decoding_work_mem65536kB65536
wal_levellogicalreplica

Two things matter here: max_slot_wal_keep_size ships as -1, and wal_level ships as replica. The first is the subject of this article; the second means you cannot create a logical slot until you raise it.

Then I pushed data into the published table, 120,000 rows per batch (with a 200-character text column so the WAL has some volume), ran CHECKPOINT after each batch, and measured how much WAL the slot was holding:

SELECT slot_name, wal_status,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained,
       pg_size_pretty(safe_wal_size) AS safe_wal
  FROM pg_replication_slots;
Stageretainedwal_statussafe_wal_size
At slot creation56 bytesreservedNULL
After batch 1 + CHECKPOINT40 MBreservedNULL
After batch 2 + CHECKPOINT79 MBreservedNULL
After batch 3 + CHECKPOINT119 MBreservedNULL
After batch 4 + CHECKPOINT159 MBreservedNULL

Four checkpoints, zero bytes reclaimed. wal_status stayed reserved the whole way and safe_wal_size stayed NULL the whole way. The documentation describes safe_wal_size as the amount of WAL that can still be written before the slot is in danger of being lost, and it is NULL when max_slot_wal_keep_size is -1. In other words, under the default configuration the safety gauge never shows you a number at all.

That is the standard cause of a disk filling up overnight on pg_wal: not a write spike, but one unread promise sitting there growing linearly with your write rate.

Test 2: with a 16 MB cap, watch wal_status walk the whole path

I set max_slot_wal_keep_size to 16 MB and reran the same experiment, this time without a checkpoint between batches so I could catch the intermediate state:

ALTER SYSTEM SET max_slot_wal_keep_size = '16MB';
SELECT pg_reload_conf();
Stageretainedwal_statussafe_wal_size
At slot creation112 bytesreserved28 MB
After batch 1 (no checkpoint)15 MBreserved13 MB
After batch 2 (no checkpoint)23 MBreserved5169 kB
After batch 3 (no checkpoint)31 MBunreserved-3305 kB
After batch 5 (no checkpoint)48 MBunreserved-20 MB
After batch 7 (no checkpoint)64 MBunreserved-36 MB
After one CHECKPOINT—lostNULL (reason = wal_removed)

Three things worth keeping.

First, safe_wal_size goes negative. It is not a strict ceiling on remaining space; it is the cap minus what is already retained, so once you cross the line it reads negative. If your monitoring sees a negative value there, you are already in the danger zone — the WAL just has not been physically removed yet because no checkpoint has run.

Second, the documentation defines four values for wal_status: reserved (claimed files are within max_wal_size), extended (max_wal_size exceeded but files still retained by the slot or by wal_keep_size), unreserved (the slot no longer retains the required WAL and some will be removed at the next checkpoint), and lost (the slot is unusable). I measured reserved → unreserved → lost. I did not observe extended; reaching it requires retained WAL to also exceed max_wal_size, which was 1 GB in my test, so I am quoting the definition rather than a measurement.

Third, unreserved is recoverable. The documentation says this state can return to reserved or extended. If the subscriber catches up, the slot survives. Only lost is unrecoverable.

Test 3: logical decoding runs out of memory too, and the spills are countable

The other half of a slot is logical decoding. A large transaction is reassembled in memory first, and anything past logical_decoding_work_mem spills to disk. That volume is queryable, through pg_stat_replication_slots.

To reproduce this on an ordinary machine I dropped logical_decoding_work_mem from its 64 MB default to 1 MB, committed a 20,000-row UPDATE transaction, and decoded it:

SET logical_decoding_work_mem = '1MB';
SELECT count(*) FROM pg_logical_slot_get_changes('ord_sub', NULL, NULL);
-- 20,002 changes decoded
SELECT slot_name, total_txns, total_bytes,
       spill_txns, spill_count, spill_bytes
  FROM pg_stat_replication_slots;
slot_nametotal_txnstotal_bytesspill_txnsspill_countspill_bytes
ord_sub14,976,004154,976,004

spill_count = 5 and spill_bytes = 4,976,004 — exactly equal to total_bytes, which means none of those 4.7 MB stayed in memory. So when decoding feels slow, the cause is often not CPU. It is logical_decoding_work_mem set too small.

Test 4: PostgreSQL 18 idle slot invalidation (off by default)

The PostgreSQL 18 release notes contain this line: "Allow inactive replication slots to be automatically invalidated using server variable idle_replication_slot_timeout." I confirmed it exists in 18.6 and that it ships as 0 (disabled):

ALTER SYSTEM SET idle_replication_slot_timeout = '10s';
SELECT pg_reload_conf();
-- wait, then trigger one checkpoint
CHECKPOINT;
SELECT slot_name, wal_status, invalidation_reason FROM pg_replication_slots;
slot_namewal_statusinvalidation_reason
ord_sublostidle_timeout
stale_sublostidle_timeout
idle_demoreserved(just created, 10s not elapsed)

Note that it is not a timer. I waited 30 seconds and nothing changed; it only took effect after I forced a CHECKPOINT. The check happens during the checkpoint, so how quickly invalidation kicks in depends on how long your checkpoint_timeout is.

This and max_slot_wal_keep_size attack the same problem from two directions: one limits space, the other limits time. I recommend setting both in production.

Three queries you can copy today

First, list every slot ordered by how much WAL it is holding, so the worst offender is on top:

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

Second, find slots that are already dead but still taking up an entry. These never clean themselves up:

SELECT slot_name, invalidation_reason
  FROM pg_replication_slots
 WHERE invalidation_reason IS NOT NULL;

Third, check whether logical decoding is thrashing to disk:

SELECT slot_name, total_txns, total_bytes,
       spill_count, spill_bytes, stream_txns, stream_bytes, stats_reset
  FROM pg_stat_replication_slots
 WHERE spill_bytes > 0;

Troubleshooting: six real errors and what to do

All six below are verbatim output from my PostgreSQL 18.6 runs, not reconstructions of "typical" errors.

Error 1: the slot is already dead

ERROR:  can no longer access replication slot "stale_sub"
DETAIL:  This replication slot has been invalidated due to "wal_removed".

This is what Test 2 produced. There is exactly one fix: drop and rebuild.

SELECT pg_drop_replication_slot('stale_sub');
-- on the subscriber, unbind first: ALTER SUBSCRIPTION x SET (slot_name = NONE); DROP SUBSCRIPTION x;

Dropping the subscription alone is not enough. The slot survives it unless you unbind first, which is the usual answer to "I disabled the subscription, so why is WAL still growing?"

Error 2: no uppercase letters in slot names

ERROR:  replication slot name "BadSlot" contains invalid character
HINT:  Replication slot names may only contain lower case letters, numbers, and the underscore character.

Both subscription names and slot names are subject to this. It bites hardest when you migrate from another database.

Error 3: duplicate slot name

ERROR:  replication slot "dup_slot" already exists

Slot names are unique cluster-wide, not per database. Two databases on the same instance will collide.

Error 4: publishing a table that does not exist

ERROR:  relation "no_such_table" does not exist

From CREATE PUBLICATION p2 FOR TABLE no_such_table;. If you script this, confirm the table exists in the publishing database first.

Error 5: reaching a slot from the wrong database

ERROR:  replication slot "ord_sub" does not exist

A logical slot is bound to one specific database (the pg_replication_slots.database column). Query a slot created in pub_demo while connected to postgres and you get "does not exist" rather than "permission denied" — which is genuinely misleading. Check which database you are connected to first.

Error 6: calling pg_logical_slot_get_changes on a pgoutput slot

ERROR:  option "proto_version" missing
CONTEXT:  slot "ord_sub", output plugin "pgoutput", in the startup callback

I hit this on my first decoding attempt. pgoutput is the wire protocol plugin for subscribers and requires options like proto_version and publication_names, so you cannot drive it by hand. Use test_decoding if you want to inspect decoded output manually:

SELECT * FROM pg_create_logical_replication_slot('ord_sub','test_decoding');

What number should you actually set?

Here is a way to turn it into arithmetic. Let T be the longest subscriber outage you want to tolerate, in hours, and R your peak write rate in MB per hour:

max_slot_wal_keep_size ≈ T × R × 1.5

The 1.5 is headroom. safe_wal_size is computed against restart_lsn, and restart_lsn advances with a lag — in my measurements it consistently trailed confirmed_flush_lsn. Better a slot that gets invalidated (rebuilding a subscription is recoverable) than a full disk (which is not).

For monitoring, I would watch the retained byte count from pg_replication_slots before I watch free disk space. When the disk alarm fires you are already fighting a fire; when retained WAL starts climbing you still have hours of warning.

FAQ

Do physical replication slots behave the same way?

Yes. Slots with slot_type = 'physical' hold a restart_lsn too and are bound by max_slot_wal_keep_size just the same. A standby that stays offline for a long time produces the identical outcome.

Can wal_keep_size replace this setting?

No. wal_keep_size is an unconditional WAL retention floor unrelated to slots; max_slot_wal_keep_size is a ceiling per slot. They stack, and both belong in your disk budget.

Will the subscriber reconnect on its own after a slot is invalidated?

No. The apply worker keeps retrying and throwing errors. You have to drop the subscription and the slot and run a fresh initial sync.

Is pg_stat_replication enough?

No. It only shows senders that are currently connected. The slots eating your disk are precisely the ones missing from it. Query pg_replication_slots.

Can I just delete the pg_replslot directory?

Do not. Slot state lives in shared memory and on disk, and pg_drop_replication_slot() is the only correct entry point. Removing the directory by hand leaves the instance misbehaving at the next checkpoint.

Wrapping up

The lesson here is not in the mechanics, it is in the default: PostgreSQL trusts that you will come back and read. That trust is harmless in a test environment and is a write-rate-timed bomb in production. Two minutes spent setting max_slot_wal_keep_size and idle_replication_slot_timeout is cheaper than a night spent deleting WAL by hand.

If you are planning a version upgrade, my PostgreSQL 17 upgrade walkthrough covers how pg_upgrade preserves logical slots from 17 onward, which connects directly to the slot lifecycle above. And if you already have a stalled production instance, PostgreSQL Troubleshooting in Production: 5 Real Errors tells the other half of the story — what sessions piling up on locks look like once WAL is pinned.

As of this writing, every number above comes from my own PostgreSQL 18.6 run (PostgreSQL 18.6 on x86_64-windows, compiled by msvc-19.44.35228, 64-bit), and the official definitions come from the PostgreSQL 18 documentation page for the pg_replication_slots view and the Release 18 notes. Verify versions and behavior against your own instance.

👉 Join Xiaomi MiMo Platform: Leading AI model platform with cost-effective inference

👉 Join Aliyun AI: Top AI products with exclusive coupons for business innovation

📌 This article was AI-assisted generated and human-reviewed | TechPassive — An AI-driven content testing site focused on real tool reviews

🔗 Recommended Tools

These are carefully selected tools. Using our affiliate links supports us to keep producing quality content:

☁️ DigitalOcean Cloud ⚡ Vultr VPS 🤖 QoderWork CN (Refer & Earn) ☁️ Aliyun AI Products 📚 WordPress Books 🔍 WordPress SEO Books 🌐 Web Hosting Books 🐳 Docker Books 🐧 Linux Books 🐍 Python Books 💰 Affiliate Marketing 💵 Passive Income Books 🖥️ Server Books ☁️ Cloud Computing Books 🚀 DevOps Books 🤖 Xiaomi MiMo Platform
← Back to Home