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:

  1. 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.
  2. 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.
check_slot_health.sql
-- 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:

debezium-postgres-orders.json
{
  "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. never skips the snapshot (only safe if a consumer genuinely only wants changes from now on), and when_needed re-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):

example-event.json
{
  "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:

replica_identity.sql
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: r records 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

FailureSymptomRoot causeFix
Slot abandonmentPrimary disk fills; retained_wal grows steadilyConnector down or deleted, slot left behindMonitor slot lag; drop orphaned slots deliberately (pg_drop_replication_slot) after confirming no consumer needs them
Snapshot never finishesConnector alive, no streaming eventsHuge table, snapshot queries contending with production loadSnapshot during quiet hours; use incremental snapshotting so the snapshot proceeds in chunks and can pause/resume
Missing deletes downstreamDeleted rows persist in the sinkTombstones disabled, or consumer ignores op: dEnable tombstones.on.delete; handle delete ops explicitly; test deletes end to end
Consumer falls behind at peakGrowing consumer lag, slot healthySink writes too slow, one row at a timeBatch upserts; parallelise by key across partitions; make the sink write path the first suspect, not Kafka
Duplicated rows in sinkCounts inflate after a rebalance or restartNon-idempotent consumer, offsets committed before writesSwitch 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.

Written by Joan Ayebola

I’m a software engineer and technical writer with more than four years of experience building software and explaining how it works. I write tested tutorials, migration guides, and documentation that help developers understand a tool quickly and use it with confidence.

Keep reading