Data engineering / Tutorial
Change Data Capture with Debezium: From Postgres WAL to a Consumer That Keeps Up
Stand up Debezium on Postgres logical decoding, protect the replication slot, and write a consumer that handles snapshots, deletes, and schema changes without losing rows.

You will build a change data capture pipeline end to end: Postgres with logical decoding enabled, a Debezium connector publishing row changes to Kafka, and a consumer that applies those changes idempotently. Along the way you will meet the three ways CDC pipelines actually fail in production — an abandoned replication slot filling the disk, a snapshot that never finishes, and a consumer that silently drops deletes — and set up the guardrails for each.
Why the WAL, and what it costs you
Debezium does not poll your tables. It reads the Postgres write-ahead log (WAL) through a logical replication slot, receiving every insert, update, and delete as an ordered stream of changes. That design buys you low latency and zero query load on the source tables, and it imposes two obligations:
- Postgres must be configured for logical decoding:
wal_level = logical, with enough replication slots and WAL senders allocated. On managed services this is a parameter-group change and usually a restart — schedule it, do not discover it. The Debezium database user also needs replication privileges plus read access to the captured tables, and it should be a dedicated account, not an application user: its credentials gate a stream of every change your database makes, including the sensitive ones, so scope and audit it like the privileged access it is. - The slot holds WAL until Debezium confirms it has consumed it. If the connector stops, the WAL piles up on the primary’s disk. An unmonitored slot is a disk-full outage on a timer. This is the most important operational fact in this article.
-- Run this on the primary, and alert on it.
-- retained_wal is the amount of WAL the slot is holding back;
-- steady growth means the consumer side is stuck, not slow.
select
slot_name,
active,
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
) as retained_wal,
pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) as unconsumed_bytes
from pg_replication_slots;The connector configuration
Debezium runs as a Kafka Connect connector. The configuration below captures a deliberately boring, production-shaped setup for an orders table:
{
"name": "orders-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "db.internal",
"database.port": "5432",
"database.user": "debezium",
"database.dbname": "shop",
"topic.prefix": "shop_server",
"plugin.name": "pgoutput",
"slot.name": "debezium_orders",
"publication.autocreate.mode": "filtered",
"table.include.list": "public.orders",
"snapshot.mode": "initial",
"tombstones.on.delete": "true",
"heartbeat.interval.ms": "10000",
"max.queue.size": "8192",
"poll.interval.ms": "500"
}
}The fields that decide your failure modes:
plugin.name: pgoutput— the built-in logical decoding plugin. Prefer it over third-party decoder plugins unless you have a specific reason; it is the path that gets the most testing.snapshot.mode: initial— on first start, Debezium snapshots the current table contents, then streams changes from a consistent WAL position.neverskips the snapshot (only safe if a consumer genuinely only wants changes from now on), andwhen_neededre-snapshots if the recorded offset is no longer available in the WAL — a pragmatic recovery setting, at the price of a possible full re-read.tombstones.on.delete: true— after a delete event, Debezium emits a tombstone record (same key, null value) so log-compacted topics can actually remove the key. Turn this off and deletes linger in compacted topics forever.heartbeat.interval.ms— on quiet tables, no changes means no offsets advance, and the slot’s confirmed position stalls, which looks like lag and retains WAL. Heartbeats force regular offset commits. Skipping heartbeats on low-traffic tables is a classic cause of mysterious slot growth.
The event envelope, and the before image trap
Each change arrives as a structured record with before, after, source, and op (c create, u update, d delete, r snapshot read):
{
"before": { "id": 1042, "status": "placed", "gross_amount": 59.99 },
"after": { "id": 1042, "status": "shipped", "gross_amount": 59.99 },
"source": { "table": "orders", "lsn": 28471936, "txId": 812 },
"op": "u",
"ts_ms": 1735689600000
}Here is the trap: by default, Postgres logical decoding includes the full before image for updates only when the changed columns warrant it, and for deletes the before image contains only the primary key — unless the table is set to REPLICA IDENTITY FULL. If your consumer needs the full prior row (audit trails, computing deltas, undoing aggregates), set it deliberately:
alter table public.orders replica identity full;Do not set it globally out of habit. Full replica identity increases WAL volume on every update and delete, because the whole old row is logged. Set it per table, where a consumer demonstrably needs the old values.
Writing a consumer that keeps up
Most CDC consumers fail not on throughput but on correctness details:
- Apply changes idempotently. Delivery from Kafka is at-least-once in any sane configuration, so your consumer will see some events twice. Upsert on the primary key, apply deletes as deletes, and make replays harmless. If your sink cannot upsert, dedupe on the source coordinates (table + LSN / transaction id) in a processed-events table.
- Respect per-key ordering, not global ordering. Events for one row are ordered; events across rows are not guaranteed to reflect a single global sequence your business logic can lean on. Design consumers that need only per-entity order. Anything needing cross-table transactional consistency needs buffering by transaction metadata, which Debezium can provide but your consumer must deliberately implement.
- Commit offsets after the write lands, not before. Commit-then-write loses data on crash; write-then-commit redelivers on crash, which your idempotency absorbs. There is no third option; choose the safe side.
- Handle the snapshot/stream seam. During the initial snapshot,
op: rrecords stream in alongside live changes. Because the snapshot is taken from a consistent position and changes are keyed, idempotent upserts reconcile the overlap: a row updated during the snapshot may arrive as a snapshot read and then as an update. Order your application logic so the streamed change wins — in practice, upsert semantics plus the fact that streaming events are applied after snapshot rows for the same key in the same partition keeps this safe; verify it in a test with a table being actively written during snapshot. - Have a poison-message strategy. One malformed event that crashes the consumer blocks the partition and, upstream, starts growing the slot. Route unprocessable records to a dead-letter topic with the error attached, alert on anything landing there, and keep the main flow moving.
Failure modes, symptoms, and fixes
| Failure | Symptom | Root cause | Fix |
|---|---|---|---|
| Slot abandonment | Primary disk fills; retained_wal grows steadily | Connector down or deleted, slot left behind | Monitor slot lag; drop orphaned slots deliberately (pg_drop_replication_slot) after confirming no consumer needs them |
| Snapshot never finishes | Connector alive, no streaming events | Huge table, snapshot queries contending with production load | Snapshot during quiet hours; use incremental snapshotting so the snapshot proceeds in chunks and can pause/resume |
| Missing deletes downstream | Deleted rows persist in the sink | Tombstones disabled, or consumer ignores op: d | Enable tombstones.on.delete; handle delete ops explicitly; test deletes end to end |
| Consumer falls behind at peak | Growing consumer lag, slot healthy | Sink writes too slow, one row at a time | Batch upserts; parallelise by key across partitions; make the sink write path the first suspect, not Kafka |
| Duplicated rows in sink | Counts inflate after a rebalance or restart | Non-idempotent consumer, offsets committed before writes | Switch to upsert-on-key; commit offsets only after sink acknowledgement |
The three alerts that matter
CDC pipelines fail quietly, so monitoring is not decoration. Three signals cover nearly every incident in this article, and each deserves an alert rather than a dashboard nobody opens. First, replication slot retention on the primary: the retained-WAL query from earlier, alerting on sustained growth rather than an absolute size, because steady growth means the consumer side is stuck and the disk is on a countdown. Second, end-to-end latency: compare the timestamp of the newest event your sink has applied against the source’s current time, using a canary row or the events’ own source timestamps, so you detect a pipeline that is technically running but hours behind. Third, consumer error rate and dead-letter volume: a dead-letter topic that receives anything at all is telling you records are being skipped, and silence from it should be the expected state, not a surprise you discover during an audit. Wire these three up before the first production table is captured, because retrofitting alerts onto a pipeline that has already lost data teaches you the pipeline’s failure modes in the worst possible order.
Backpressure and capacity, qualitatively
Debezium itself is rarely the bottleneck; the chain is only as fast as its slowest link, and the slowest link is usually the sink’s write pattern. Size max.queue.size and batch behaviour with headroom for your peak write rate, not your average — CDC traffic inherits your OLTP traffic’s spikes, including the batch job someone runs at night that updates a million rows and becomes a million events. Decide in advance what the pipeline does under that spike: lag and catch up (usually right for analytics sinks) or shed (almost never acceptable for replication sinks). Test the spike before production tests it for you.
Verdict
Debezium on Postgres is the right default when you need row-level changes with low latency and cannot tolerate polling load on the source — replication, cache invalidation, search indexing, and event publishing all fit. The technology is the easy half. The production half is operational discipline: alert on replication slot retention from day one, heartbeat quiet tables, set replica identity per table with intent, keep tombstones on, and write consumers that assume duplicates and handle deletes.
If your freshness requirement is measured in hours rather than seconds, honestly compare all this against a scheduled incremental extract with dbt or a batch loader. CDC earns its complexity only when the latency or the delete-propagation actually matters. When it does matter, there is no substitute that avoids the WAL — there is only doing the WAL properly.


