Change Data Capture with Debezium

BackendDatabasesArchitecture
Share on LinkedIn

Polling WHERE updated_at > ? every five minutes was our integration strategy until a product manager asked why deleted users still received emails. Deletes don't bump updated_at. Change Data Capture from the transaction log captures the truth — inserts, updates, and deletes — without hammering the primary with full-table scans.

How Debezium fits

PostgreSQL ──WAL──▶ Debezium Connector ──▶ Kafka ──▶ Flink/dbt/Snowflake

Debezium runs as a Kafka Connect connector (or Debezium Server for non-Kafka sinks). It reads the database's replication stream, not application queries.

PostgreSQL setup sketch

Enable logical replication:

ALTER SYSTEM SET wal_level = logical;
-- restart required

CREATE PUBLICATION dbz_publication FOR TABLE public.orders, public.customers;

CREATE ROLE debezium REPLICATION LOGIN PASSWORD '...';
GRANT SELECT ON ALL TABLES IN SCHEMA public TO debezium;

Connector config:

{
  "name": "postgres-orders",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "postgres-primary",
    "database.port": "5432",
    "database.user": "debezium",
    "database.password": "${secrets}",
    "database.dbname": "app",
    "topic.prefix": "prod",
    "table.include.list": "public.orders,public.customers",
    "plugin.name": "pgoutput",
    "publication.name": "dbz_publication",
    "slot.name": "debezium_orders",
    "transforms": "unwrap",
    "transforms.unwrap.type": "io.debezium.transforms.ExtractNewRecordState",
    "transforms.unwrap.drop.tombstones": "false"
  }
}

ExtractNewRecordState simplifies payloads for downstream consumers.

Event shape

Typical change record:

{
  "op": "u",
  "before": { "id": 1, "status": "pending", "total": 100 },
  "after":  { "id": 1, "status": "paid", "total": 100 },
  "source": { "table": "orders", "lsn": 12345678 },
  "ts_ms": 1724000000000
}

op: c create, u update, d delete, r snapshot read. Tombstones on delete keys for Kafka log compaction.

Initial snapshot + streaming

First connector start optionally snapshots existing table data, then switches to streaming. snapshot.mode options:

Large tables: parallel snapshot, filter predicates, or pre-load warehouse then start streaming from LSN.

Ordering and partitioning

Kafka partition by primary key hash preserves per-key ordering, not global table order. Design consumers idempotently — same key may redeliver on failure.

Cross-table transactions appear as separate events — no atomic multi-table message. Sagas or outbox pattern for coordinated publishes.

Schema evolution

Debezium publishes to Schema Registry (Avro/JSON Schema/Protobuf). Add nullable columns safely; renaming columns appears as drop+add — treat as breaking in consumers.

Enable include.schema.changes topic for audit; automate consumer compatibility tests in CI.

Operational concerns

Replication slots on Postgres consume WAL — monitor lag; orphaned slots fill disks.

Binlog retention on MySQL — debezium heartbeat must keep pace.

High-volume tables — filter columns, separate connectors, or event flattening to reduce payload size.

Secrets rotation — connector restarts; use Kafka Connect secret providers.

Monitor: MilliSecondsSinceLastEvent, snapshot progress, connector FAILED state.

Downstream patterns

Pick primary key carefully — composite keys need composite Kafka keys.

Debezium Server without Kafka

Not every team runs Kafka. Debezium Server pushes changes directly to sinks:

# application.properties
debezium.sink.type=pubsub
debezium.sink.pubsub.project.id=my-project
debezium.source.connector.class=io.debezium.connector.postgresql.PostgresConnector
debezium.source.database.hostname=postgres-primary
debezium.source.table.include.list=public.orders

Supported sinks: Amazon Kinesis, Google Pub/Sub, Amazon SQS, Redis, Apache Pulsar, NATS JetStream. Simpler ops for teams without Kafka expertise — tradeoff is losing Kafka's replay and multi-consumer fan-out.

Monitoring replication health

Critical metrics for Debezium operations:

Metric Alert threshold Action
MilliSecondsSinceLastEvent >300000 (5 min) Check connector status, DB connectivity
Replication slot lag (bytes) >1GB Investigate slow consumer or connector pause
Snapshot progress Stalled >1 hour Check table size, parallel snapshot config
Connector state FAILED Auto-restart with backoff; check error logs
Schema change events Any breaking change Notify consumers, run compatibility tests

Postgres replication slot monitoring is critical — orphaned slots prevent WAL cleanup and fill disk:

SELECT slot_name, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS lag
FROM pg_replication_slots;

Alert when any slot lag exceeds 10GB.

Outbox pattern integration

Debezium's outbox event router transform converts transactional outbox rows to clean domain events:

-- Application writes to outbox in same transaction as business data
INSERT INTO outbox (aggregate_id, event_type, payload)
VALUES ('order-123', 'OrderCreated', '{"orderId": "order-123", "total": 5000}');
// Debezium outbox transform config
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
"transforms.outbox.route.topic.replacement": "events.${routedByValue}"

Debezium reads outbox table changes and routes to Kafka topics by event type — no separate relay process needed. See outbox pattern guide.

Failure modes

Production checklist

Monitor replication slot lag as a paging alert, not a dashboard tile — WAL bloat from stalled CDC takes down the primary database.

Resources

Frequently asked questions

What is Debezium?

Debezium is an open-source CDC platform built on Kafka Connect. It reads database transaction logs — PostgreSQL WAL, MySQL binlog, SQL Server CDC — and publishes row-level change events (insert, update, delete) to Kafka topics with before/after payloads and schema metadata.

Why use CDC instead of timestamp-based polling?

Polling misses deletes, adds query load on source databases, and struggles with backfills. CDC captures every committed change in order with low latency and minimal impact on OLTP workloads when configured correctly.

How do I handle schema changes with Debezium?

Register schemas in Schema Registry; Debezium emits schema change events. Use backward-compatible Avro evolution. Consumers must handle optional new fields. For breaking changes, coordinate expand-contract migrations and snapshot reloads.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →