How Debezium reads PostgreSQL: logical replication, slots and the pipeline
Debezium's PostgreSQL connector is a logical replication client. It does not poll tables or rely on triggers. It connects to the primary over the replication protocol and receives a decoded stream of row-level changes from the write-ahead log (WAL), the same log PostgreSQL uses for crash recovery and physical replicas.
Three server-side pieces make this work:
wal_level=logicalmakes PostgreSQL write enough information into the WAL to reconstruct row changes, not just physical page modifications.- The
pgoutputplugin, which ships with PostgreSQL, decodes WAL records into logical messages: begin, insert, update, delete and commit. A publication controls which tablespgoutputemits. - A replication slot stores the position of the oldest WAL the consumer still needs and the LSN (log sequence number) the consumer has confirmed. PostgreSQL will not remove WAL past that position.
The pipeline runs in this order:
- A transaction commits on the primary.
pgoutputdecodes it through the slot.- The Debezium connector, running as a Kafka Connect task, turns each row change into an event.
- Kafka Connect writes the event to a per-table topic.
- Downstream services consume that topic.
Debezium records its own offsets in Kafka. It then confirms the corresponding LSN back to the slot, which is what lets PostgreSQL discard older WAL.
The slot is a resource you operate, separate from the connector. It persists when the connector is stopped, crashed or deleted. While the slot is inactive, the primary keeps every WAL segment generated after the slot's position. Disk usage on your production database grows until someone consumes from the slot or drops it.
+- PostgreSQL primary (wal_level=logical) ---------------------------------+
| +-------------+ +-----------------+ +--------------------------+ |
| | transaction |--->| WAL records |--->| pgoutput plugin | |
| | commits | | in pg_wal | | tables in PUBLICATION | |
| +-------------+ +--------+--------+ +-------------+------------+ |
| ^ pins WAL from | |
| | restart_lsn v |
| +----------+---------------------------+------------+ |
| | replication slot: restart_lsn, confirmed_flush_lsn| |
| | inactive slot => WAL retained, primary disk grows | |
| | persists if connector stops, crashes or is deleted| |
| +-------------------+-----------+-------------------+ |
+---------------------------------------+-----------+----------------------+
change stream | ^ confirm LSN
v |
+---------------------------------------+-----------+----------------------+
| Kafka Connect worker |
| Debezium PostgreSQL connector task: row change -> change event |
| offsets stored in Kafka, then LSN confirmed to slot |
+------------+-----------------------+-----------------------+-------------+
v v v
+---------+--------+ +---------+--------+ +---------+--------+
| topic: users | | topic: orders | | topic: payments |
+---------+--------+ +---------+--------+ +---------+--------+
v v v
+---------+--------+ +---------+--------+ +---------+--------+
| consumer svc A | | consumer svc B | | consumer svc C |
+------------------+ +------------------+ +------------------+
Committed changes flow from the WAL through pgoutput and the replication slot to the Debezium connector in Kafka Connect, then to per-table topics and their consumers; the connector confirms LSNs back to the slot, and the slot pins WAL on the primary until it does.
When to use CDC, who it is for, and when to avoid it
That operational cost is worth paying when the problem CDC solves is real. The core problem is the dual write: a service updates PostgreSQL and then publishes an event or updates Elasticsearch, Redis or a warehouse. If the process crashes between the two steps, the systems diverge, and nothing reconciles them. CDC removes the second write. The database commit is the single source of truth, and every downstream system derives its state from the WAL in commit order.
Typical uses:
- Search indexes that must reflect inserts, updates and deletes without nightly rebuilds.
- Caches that are refreshed or evicted from actual row changes rather than application code paths that someone forgets to update.
- Analytics pipelines that need a row-level change history rather than periodic full-table dumps.
- Service integration, where other teams react to state changes without calling your API or reading your database.
The payoff grows with the number of consumers. A platform team running Kafka for several backend teams pays the infrastructure cost once and adds consumers cheaply.
Avoid it when there is one consumer, or when you only need cache invalidation. Running Kafka, Kafka Connect and a schema registry for that is disproportionate. PostgreSQL's LISTEN/NOTIFY, or a periodic poll on an updated_at column, is often enough.
Be wary of streaming domain tables directly. Every column becomes part of a contract other teams depend on, so renaming a field or splitting a table turns into a cross-team migration. The usual mitigation is the outbox pattern. The service writes an explicit event row to an outbox table in the same transaction as its domain change, and Debezium captures only that table. Consumers then depend on a deliberately designed event schema rather than your internal tables.
Preparing PostgreSQL and registering the connector
Prepare PostgreSQL
Set wal_level = logical so the WAL carries enough detail to decode row changes. This setting requires a server restart, so plan it into a maintenance window. Set max_replication_slots and max_wal_senders with headroom above current use. Each connector holds one slot and one sender connection, and physical replicas and backup tools draw from the same pools. Running out stops a connector from starting.
Create a dedicated login role with the REPLICATION attribute. Grant it SELECT only on the tables you intend to capture, because the initial snapshot reads them with ordinary queries. A narrow role limits what a leaked credential exposes.
Create the publication yourself with CREATE PUBLICATION ... FOR TABLE and list only the required tables, such as the outbox. Then set publication.autocreate.mode to disabled. This stops Debezium from creating a publication covering every table when the role happens to hold enough privilege to do so.
Register the connector
The connector class is io.debezium.connector.postgresql.PostgresConnector. The essential properties are:
- Connection:
database.hostname,database.port,database.user,database.passwordanddatabase.dbname. plugin.name=pgoutput: uses PostgreSQL's built-in decoder, so no server extension is needed.slot.nameandpublication.name: must be unique per connector.topic.prefix: namespaces the topics. Changing it later means new topic names.table.include.list: matching the publication.
POST the JSON to Kafka Connect's /connectors endpoint with curl. Then query /connectors/<name>/status. Both the connector and its task should report RUNNING. A FAILED task includes a stack trace in that response.
#!/usr/bin/env bash
# Register a Debezium PostgreSQL connector and check its status.
# Assumes the publication 'app_outbox_pub' already exists and the
# 'debezium_cdc' role has REPLICATION plus SELECT on public.outbox.
set -euo pipefail
CONNECT_URL=""
curl -sS -X POST "$CONNECT_URL/connectors" \
-H 'Content-Type: application/json' \
-d @- <<EOF
{
"name": "orders-outbox-cdc",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "pg-primary.internal",
"database.port": "5432",
"database.user": "debezium_cdc",
"database.password": "${PG_CDC_PASSWORD}",
"database.dbname": "orders",
"plugin.name": "pgoutput",
"slot.name": "orders_outbox_slot",
"publication.name": "app_outbox_pub",
"publication.autocreate.mode": "disabled",
"topic.prefix": "orders",
"table.include.list": "public.outbox"
}
}
EOF
# Give the task a moment to start, then confirm connector and task state.
# Expect "RUNNING" for both; a FAILED task includes a "trace" field.
sleep 5
curl -sS "$CONNECT_URL/connectors/orders-outbox-cdc/status"
A curl-based registration of a pgoutput Debezium connector bound to a pre-created publication and a dedicated slot, followed by a status check of the connector and its task.
Initial snapshots, change events and REPLICA IDENTITY
Once the task is RUNNING, the connector creates its replication slot before it reads any rows. Creating the slot first pins the WAL from that LSN onward, so no change can fall into the gap between the snapshot and streaming. The connector then reads each captured table with SELECT inside a single repeatable-read transaction and emits every row as an event with op: "r". When the snapshot finishes, it streams from the slot's starting LSN.
With the default initial mode, a large database means hours of sequential scans on the primary and WAL piling up behind the unconsumed slot. initial_only snapshots and stops, with no streaming. Incremental snapshots avoid one long transaction. You configure a signaling table and insert an execute-snapshot signal. The connector then reads tables in primary-key chunks interleaved with streaming, and you can trigger the same mechanism ad hoc to backfill a newly added table. The price is extra configuration: the signal table, permissions, and consumers that tolerate r and u events for the same key arriving close together.
Each event's value holds before, after, op (c, u, d, r) and source. source includes the lsn and txId, which are useful for deduplication. A create has a null before. A delete has a null after. A delete is followed by a tombstone, which has the same key and a null value, so Kafka log compaction can eventually drop the key.
REPLICA IDENTITY DEFAULT |
REPLICA IDENTITY FULL |
|
|---|---|---|
Update before |
null (unless the key changed) | full old row |
Delete before |
primary-key columns only | full old row |
| Unchanged TOASTed columns | placeholder value | available |
| WAL volume | baseline | higher, since old rows are logged |
Use FULL only on tables whose consumers need diffs.
-- Signaling table for incremental / ad hoc snapshots.
-- The connector property signal.data.collection must name it: public.debezium_signal
CREATE TABLE public.debezium_signal (
id VARCHAR(42) PRIMARY KEY, -- arbitrary unique signal id
type VARCHAR(32) NOT NULL, -- e.g. 'execute-snapshot'
data VARCHAR(2048) -- JSON payload for the signal
);
-- The connector user must be able to read the signal table.
GRANT SELECT ON public.debezium_signal TO debezium_user;
-- Backfill a newly captured table in primary-key chunks,
-- interleaved with streaming, instead of one long repeatable-read scan.
INSERT INTO public.debezium_signal (id, type, data)
VALUES (
'backfill-orders-001',
'execute-snapshot',
'{"data-collections": ["public.orders"], "type": "incremental"}'
);
-- Opt in to full before-images only where consumers need them;
-- every UPDATE and DELETE on this table now logs the whole old row to WAL.
ALTER TABLE public.orders REPLICA IDENTITY FULL;
-- Check the current setting: d = DEFAULT, f = FULL, i = INDEX, n = NOTHING.
SELECT relname, relreplident
FROM pg_class
WHERE oid = 'public.orders'::regclass;
-- Revert if the extra WAL volume is not worth it.
-- ALTER TABLE public.orders REPLICA IDENTITY DEFAULT;
Creates a Debezium signaling table, triggers an incremental snapshot of one table, and switches that table to REPLICA IDENTITY FULL while showing how to verify or revert the setting.
Topic keys, ordering and consumers that survive at-least-once delivery
By default Debezium writes each table to its own topic, named from the configured topic prefix, schema and table. The message key holds the row's primary-key columns, so every change to a given row hashes to the same partition. Kafka preserves order within a partition, which means you get per-row ordering only. Changes to two different rows, rows in two tables, or all the statements in one transaction can arrive interleaved or out of commit order. If a consumer needs transactional grouping, it has to reassemble it, for example from the transaction metadata Debezium can emit.
Tables without a primary key have no natural key. Their events lose per-row ordering, and log compaction cannot collapse them. Add a key, or declare substitute key columns in the connector configuration.
Consumers that tolerate replays
Delivery is at-least-once, so after a connector restart you will see duplicates and replayed history. Write each event as an upsert keyed on the primary key. Store the event's source.lsn, or an application version column, alongside the row, and skip any event whose value is not newer than the stored one. Treat a delete event as a delete, or as a soft-delete marker if late updates might still arrive. Treat the null-value tombstone that follows a delete as a signal for compaction, not as business data.
Schema changes
Logical replication does not stream DDL. A new column shows up only when the next row change carries it. Sequence migrations accordingly:
- Deploy consumers that ignore unknown fields.
- Add nullable columns.
- Backfill the new columns.
- Only after every consumer has stopped reading a column, drop or rename it.
import json
# Assumes the JSON converter with schemas disabled, so each value is the bare
# Debezium envelope: {"before", "after", "source", "op", ...}.
UPSERT = """
INSERT INTO customers_replica (id, email, name, deleted, source_lsn)
VALUES (%(id)s, %(email)s, %(name)s, %(deleted)s, %(lsn)s)
ON CONFLICT (id) DO UPDATE
SET email = EXCLUDED.email, name = EXCLUDED.name,
deleted = EXCLUDED.deleted, source_lsn = EXCLUDED.source_lsn
-- Stale or replayed event: only apply if strictly newer than what we stored.
WHERE customers_replica.source_lsn < EXCLUDED.source_lsn
"""
def handle(cur, raw_key, raw_value):
# Tombstone after a delete: exists for log compaction, not business data.
if raw_value is None:
return
event = json.loads(raw_value)
lsn = event["source"]["lsn"]
if event["op"] == "d":
# Soft delete keeps the row and its LSN, so a late, older update
# replayed after a restart cannot resurrect it.
pk = json.loads(raw_key)["id"]
row = {"id": pk, "email": None, "name": None, "deleted": True, "lsn": lsn}
else:
# c = insert, u = update, r = snapshot read: all become upserts.
after = event["after"]
row = {"id": after["id"],
"email": after.get("email"),
"name": after.get("name"), # unknown new fields are ignored
"deleted": False, "lsn": lsn}
cur.execute(UPSERT, row)
# Commit the DB transaction before committing the Kafka offset, so a crash
# in between yields a harmless replay rather than a lost change.
An idempotent Python consumer that upserts Debezium change events keyed on the primary key, discards stale or replayed events by comparing source.lsn, turns deletes into soft deletes, and ignores tombstones.
Operating the pipeline: slot lag, WAL retention, failover and a runbook
A stalled connector does not just delay events. It keeps its replication slot pinned, and PostgreSQL will retain every WAL segment from the slot's restart_lsn onward until the disk fills and the primary stops accepting writes. Alert on retained WAL, not only on consumer lag.
What to watch
- Retained WAL per slot: compute
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)frompg_replication_slots. Alert when this number keeps growing, and set the threshold well below your free disk. - Slot activity: check the
activeandactive_pidcolumns. An inactive slot that is still holding WAL means no one is reading it. - Retention guardrail: consider setting
max_slot_wal_keep_size. PostgreSQL will then invalidate a slot that falls too far behind instead of filling the disk. The trade-off is that you will need to resnapshot afterward. - Task status: call
GET /connectors/<name>/statuson the Kafka Connect REST API. A connector can reportRUNNINGwhile one of its tasks isFAILED, so check each task state.
Failover
Logical slots exist only on the primary unless you configure slot synchronization to standbys. Recent PostgreSQL releases support this. Without it, a promoted standby has no slot, and changes made around the promotion can be lost. Test a promotion before you rely on the pipeline.
Runbook
- Failed task: read the stack trace in the status response, fix the cause, then
POST /connectors/<name>/tasks/<id>/restart. - Resnapshot: for selected tables, trigger an incremental snapshot through the Debezium signaling table. If the slot was lost, recreate the connector with a fresh slot and run a full snapshot.
- Abandoned slot: confirm the slot is
active = false. Delete the connector first so it cannot recreate the slot. Then runpg_drop_replication_slot('<slot>')and drop the publication if nothing else uses it.
Alternatives and impact on cost and engineering time
Debezium is not the only way to move PostgreSQL changes into other services, and for some workloads it is more machinery than the problem needs.
| Approach | Captures deletes | Infrastructure added | Main failure mode |
|---|---|---|---|
| Application dual writes | Only if coded | None | Database and broker disagree after a partial failure |
| Transactional outbox, polled | Yes, as explicit events | Relay process | Polling delay; outbox table growth |
| Outbox relayed by Debezium | Yes | Kafka, Connect | Same slot risks, but events are deliberately shaped |
Polling on updated_at |
No (needs soft deletes) | Scheduler | Missed rows from clock skew or same-timestamp commits |
| Triggers to audit table | Yes | None | Extra write cost inside every transaction |
| Direct logical replication or LISTEN/NOTIFY | Replication: yes; NOTIFY: only if sent | Custom consumer | You own slot handling; NOTIFY is lost if no listener is connected |
Dual writes cannot be fixed by retries, because no single transaction covers both systems. That is the main reason to adopt CDC or an outbox. If you already run Kafka, the extra cost of Debezium is mostly Kafka Connect, a schema registry, and the slot and WAL monitoring covered above. If you do not run Kafka, a polled outbox is often the cheaper way to get the same correctness.
Illustrative estimate (assumed numbers, not a benchmark)
Net hours per month = (sync-job upkeep + dual-write incident time) − (pipeline operations + idempotency upkeep)
Assumed inputs:
- Sync-job upkeep: 3 jobs × 6 h = 18 h
- Dual-write incident time: 2 incidents × 8 h = 16 h
- Pipeline operations: 12 h
- Idempotency upkeep: 6 h
Result: 34 h − 18 h = 16 h saved per month. This excludes the one-time cost of setting up the pipeline.
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 debezium postgresql cdc, 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.