Change data capture on Postgres has a reputation for going wrong at 2 a.m. Some downstream sync stalls, and the primary’s disk starts filling. Reaching for a bigger disk treats the symptom. The cause is almost always a single object that most teams turn on without looking at: the logical replication slot. This piece follows one sync from setup to steady state and opens the hood at each step, so that when something stalls you know which server-side lever moved.
Everything below is grounded in the PostgreSQL 17 documentation, and the SQL loop at the end was run against PostgreSQL 17.10 to confirm it behaves as the manual describes. Protocol details quote the manual directly. Where a command is drawn from the docs without a live run, it says so and is labeled source-derived.
Server configuration
Postgres gives you two front doors to CDC. The high-level one is built in replication: “Logical replication uses a publish and subscribe model with one or more subscribers subscribing to one or more publications on a publisher node” [LR-PUBSUB-1]. It replicates “data objects and their changes, based upon their replication identity (usually a primary key)” [LR-DEF-1]. The low-level door is logical decoding, where you own the slot directly and read raw change records. Airbyte-style connectors use the low-level door because they move changes into systems that are not Postgres.
Both doors need the same server setting. Per the manual, “Before you can use logical decoding, you must set wal_level to logical and max_replication_slots to at least 1” [CFG-WALLEVEL-1]. Changing wal_level takes a server restart, so do it once, up front, before anyone is waiting on the sync.
The snapshot comes before the stream
A common wrong mental model is that CDC starts at “now” and streams forward. It does not. A sync starts by copying what already exists. “Logical replication of a table typically starts with taking a snapshot of the data on the publisher database and copying that to the subscriber. Once that is done, the changes on the publisher are sent to the subscriber as they occur in real-time. The subscriber applies the data in the same order as the publisher so that transactional consistency is guaranteed for publications within a single subscription” [LR-SNAP-1]. On the built-in path this is automatic: “All subscriptions will copy initial data by default” [SUB-COPY-1].
So the order is copy first, then stream. The slot is what makes the handoff between those two phases lossless.
What the slot is
Here is the idea that carries the rest. “In the context of logical replication, a slot represents a stream of changes that can be replayed to a client in the order they were made on the origin server” [SLOT-DEF-1]. Two properties make it more than a cursor.
First, it is scoped: “Each slot streams a sequence of changes from a single database” [SLOT-DB-1], so a slot maps to exactly one database. Second, it is durable. “Slots persist independently of the connection using them and are crash-safe” [SLOT-PERSIST-1], and they “persist across crashes and know nothing about the state of their consumer(s)” [SLOT-PERSIST-2]. That last clause is worth pausing on, because the slot does not know whether your consumer is healthy. It only knows how far the consumer has confirmed. If the consumer dies, the slot waits patiently for as long as it takes.
What the slot hands you is decoded change, not raw WAL. “Logical decoding is the process of extracting all persistent changes to a database’s tables into a coherent, easy to understand format which can be interpreted without detailed knowledge of the database’s internal state” [DECODE-DEF-1]. The translation is done by an output plugin: “Output plugins transform the data from the write-ahead log’s internal representation into the format the consumer of a replication slot desires” [PLUGIN-1]. test_decoding is the plugin that comes with Postgres for experiments. pgoutput is the one the built-in path uses. A connector may bring its own.
What travels over the wire
When your consumer starts streaming, it is not running SQL. The slot commands live on a separate protocol. “These commands are only available over a replication connection; they cannot be used via SQL” [PROTO-CMD-1]. The client tool pg_recvlogical speaks that protocol for you: it “can be used to control logical decoding over a streaming replication connection. (It uses these commands internally.)” [PROTO-RECV-1].
Now watch one transaction cross the wire. The protocol “sends individual transactions one by one. This means that all messages between a pair of Begin and Commit messages belong to the same transaction” [PROTO-TXN-1]. Inside that Begin/Commit envelope, “every sent transaction contains zero or more DML messages (Insert, Update, Delete)” [PROTO-DML-1]. Before any row change references a table, the schema arrives first: “Before the first DML message for a given relation OID, a Relation message will be sent, describing the schema of that relation” [PROTO-REL-1]. Custom types announce themselves earlier still, because “for a non-built-in type OID, a Type message will be sent before the Relation message, to provide the type name associated with that OID” [PROTO-TYPE-1].
That ordering is why a consumer can decode a change without holding the publisher’s catalog. The stream carries its own schema. One more case matters for large writes. Rather than buffering a huge transaction until commit, Postgres “also sends changes of large in-progress transactions between a pair of Stream Start and Stream Stop messages” [PROTO-STREAM-1], so a bulk load does not stall the pipe until the very end.
Peek versus consume
This is the single most useful distinction in the whole system. Two functions read the same slot, and only one of them advances it.
pg_logical_slot_get_changes “returns changes in the slot slot_name, starting from the point from which changes have been consumed last” [FUNC-GET-1]. Calling it moves the slot forward. Its twin, pg_logical_slot_peek_changes, “behaves just like the pg_logical_slot_get_changes() function, except that changes are not consumed; that is, they will be returned again on future calls” [FUNC-PEEK-1].
Peek is how you test a pipeline without spending the data. Get is how a real consumer makes progress. The slot’s memory of “how far consumed” is visible as confirmed_flush_lsn, “the address (LSN) up to which the logical slot’s consumer has confirmed receiving data. Data corresponding to the transactions committed before this LSN is not available anymore” [VIEW-CONFIRM-1]. The slot only lets go of data the consumer has confirmed. That is the guarantee that makes CDC safe, and it is also the rope you can hang the primary with.
WAL retention and the pg_replication_slots columns
The same durability that prevents data loss is what fills your disk. A slot “will prevent removal of required resources even when there is no connection using them. This consumes storage because neither required WAL nor required rows from the system catalogs can be removed by VACUUM as long as they are required by a replication slot” [SLOT-RETAIN-1]. A dead consumer does not free anything. The slot keeps the WAL because, as far as it knows, someone still needs it.
Three columns in pg_replication_slots tell you the health of every slot before it becomes a page. Watch them.
active: “True if this slot is currently being streamed” [VIEW-ACTIVE-1]. A slot that should be live but reads false is a stalled consumer.restart_lsn: “the address (LSN) of oldest WAL which still might be required by the consumer of this slot and thus won’t be automatically removed during checkpoints unless this LSN gets behind more than max_slot_wal_keep_size from the current LSN” [VIEW-RESTART-1]. The distance between this and the current LSN is your retained WAL.wal_status: the manual defines an escalating scale ofreserved, thenextended, thenunreserved, ending inlost, which “means that this slot is no longer usable” [VIEW-WALSTATUS-1].
If active is false while restart_lsn falls further behind and wal_status climbs that scale, you have found your 2 a.m. incident in daylight.
A loop you can run
Here is the minimal cycle on the SQL side. This one was run against PostgreSQL 17.10 (the official postgres:17 Docker image, started with wal_level=logical and max_replication_slots=4), and the observed output is folded into the comments below. Create the slot with the bundled test plugin, which mirrors the protocol command CREATE_REPLICATION_SLOT ... LOGICAL [FUNC-CREATE-1]:
-- PostgreSQL 17.10, verified against the official postgres:17 image
SELECT * FROM pg_create_logical_replication_slot('regression_slot', 'test_decoding', false, true);
SELECT slot_name, plugin, slot_type, database, active, restart_lsn, confirmed_flush_lsn, wal_status
FROM pg_replication_slots;
-- regression_slot | test_decoding | logical | postgres | f | 0/14ED878 | 0/14ED8B0 | reserved
-- Look without spending. Run it twice: the same rows come back both times.
SELECT * FROM pg_logical_slot_peek_changes('regression_slot', NULL, NULL); -- 5 rows
SELECT * FROM pg_logical_slot_peek_changes('regression_slot', NULL, NULL); -- the same 5 rows
-- Spend the changes. The first call returns them and advances the slot;
-- the second call is empty because the slot has moved past them.
SELECT * FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL); -- 5 rows
SELECT * FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL); -- 0 rows
The run bears out every claim above. A fresh slot reports wal_status as reserved, the healthy baseline of the scale from the previous section. The two peeks return the same five change records, one Begin and Commit pair around a table creation and a second wrapping the insert of a single row. The first get returns those same five and moves confirmed_flush_lsn forward. The second get returns nothing, because the confirmed changes are no longer available.
To stream over the wire instead, pg_recvlogical runs the same slot lifecycle from a shell (source-derived, PostgreSQL 17):
pg_recvlogical -d postgres --slot=test --create-slot
pg_recvlogical -d postgres --slot=test --start -f -
pg_recvlogical -d postgres --slot=test --drop-slot
When you are done experimenting, drop the slot. Leaving an idle slot behind is exactly the disk-retention trap above. pg_drop_replication_slot “drops the physical or logical replication slot named slot_name” [FUNC-DROP-1]:
SELECT pg_drop_replication_slot('regression_slot');
On the built-in path you rarely type any of this, because “each subscription will receive changes via one replication slot” [SUB-SLOT-1] and “normally, the remote replication slot is created automatically when the subscription is created using CREATE SUBSCRIPTION and it is dropped automatically when the subscription is dropped using DROP SUBSCRIPTION” [SUB-SLOTMGMT-1]. The subscription is “the downstream side of logical replication” [SUB-DEF-1]. The slot is still there, doing the same job, whether you named it or Postgres did.
What to check first
CDC on Postgres is not fragile. It is honest. The slot keeps exactly what an unconfirmed consumer might still need, and it tells you the truth about that in three columns. So when a sync goes quiet, do not start with the connector logs. Start with pg_replication_slots. Read active, read how far restart_lsn trails the current LSN, read wal_status. The slot already knows what went wrong. This piece was about learning to read it.
Written by Context Systems. This piece was researched from the project’s public docs, release notes, and source. The way we write everything.