Education › Data › Stage 3: Scale & streaming

Change data capture & incremental loads

Log-based CDC, Debezium-style pipelines, merges, snapshots and keeping a replica honest.

Intermediate ~35 min read Module 12 of 16

The batch module left a problem open: timestamp-based extraction cannot see deletes, misses rows whose updated_at was never set, and hammers the source with full scans of hot tables. Change data capture reads the database's own transaction log instead — the same log the database uses for replication — and turns every insert, update and delete into an event, in commit order, with almost no load on the source. It is how a warehouse or a lakehouse becomes a faithful, near-real-time replica of production, and how downstream systems learn about changes without polling. This module covers how log-based CDC works, the Debezium-style pipeline through Kafka, applying changes into a table format with merges, initial snapshots, schema changes, and the trade-offs that decide between CDC and simpler batch.

After this module you can
  • Explain log-based CDC and why it beats query-based extraction for correctness and source load
  • Set up a Postgres to Kafka CDC pipeline with Debezium: replication slot, publication, connector, change event shape
  • Apply change events into a lakehouse or warehouse table with idempotent merges, including deletes
  • Handle initial snapshots, schema evolution and connector restarts without losing or duplicating changes
  • Decide when CDC is worth its operational cost

Why the log, not the table

Every transactional database writes changes to a write-ahead log (Postgres WAL, MySQL binlog, SQL Server transaction log) before applying them, and uses that log for crash recovery and replication. Log-based CDC attaches a reader to that log and decodes it into change events: for each committed row change, the table, the operation (create, update, delete), the row before and after, the transaction id and the commit position. Because it reads what the database already writes, it sees every change including deletes and columns without timestamps, in exact commit order, without querying the tables at all.

PropertyQuery-based (timestamps)Log-based CDC
Sees deletesNoYes
Sees every update, even rapid onesOnly the last state per pollEvery committed change
Load on the sourceRepeated scans of hot tablesReads the log; negligible
LatencyPoll interval (minutes to hours)Seconds
Requires source changesA maintained updated_at columnLogical replication enabled; slot management
Operational complexityLowConnector, Kafka, slot monitoring, schema handling
Note

Trigger-based CDC (database triggers writing to a change table) is a third option: works where log access is unavailable, but adds write latency to every transaction on the source. Prefer the log where you can.

A Debezium pipeline from Postgres

The most common open-source setup is Debezium running in Kafka Connect: a connector per source database reads the log and publishes one Kafka topic per table, keyed by the row's primary key. Postgres needs wal_level = logical, a replication slot (the server keeps WAL until the slot's consumer has read it) and a publication listing the tables. The connector records its position (LSN) in Kafka Connect's offset topic, so a restart resumes exactly where it stopped.

Preparing Postgres: logical WAL, a replication user, and a publication for the tables to capture.
bash
# postgresql.conf (restart required)
#   wal_level = logical
#   max_replication_slots = 10
#   max_wal_senders = 10

psql -U postgres -d shop <<'SQL'
CREATE ROLE debezium WITH LOGIN REPLICATION PASSWORD 'REPLACE_ME';
GRANT SELECT ON ALL TABLES IN SCHEMA public TO debezium;
CREATE PUBLICATION shop_pub FOR TABLE public.orders, public.customers;
ALTER TABLE public.orders REPLICA IDENTITY FULL;   -- include full 'before' image on updates/deletes
SQL
The connector configuration submitted to Kafka Connect. The slot and publication names must match, and the snapshot mode decides how existing rows are captured.
json
{
  "name": "shop-postgres",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "db.internal",
    "database.port": "5432",
    "database.user": "debezium",
    "database.password": "${secrets:shop/debezium:password}",
    "database.dbname": "shop",
    "topic.prefix": "shop",
    "plugin.name": "pgoutput",
    "slot.name": "debezium_shop",
    "publication.name": "shop_pub",
    "table.include.list": "public.orders,public.customers",
    "snapshot.mode": "initial",
    "tombstones.on.delete": "true",
    "key.converter": "io.confluent.connect.avro.AvroConverter",
    "value.converter": "io.confluent.connect.avro.AvroConverter",
    "value.converter.schema.registry.url": "http://schema-registry:8081",
    "heartbeat.interval.ms": "10000"
  }
}

Each change event carries an envelope: op (c create, u update, d delete, r read during snapshot), before and after row images, and source metadata (LSN, transaction id, commit timestamp). Deletes are followed by a tombstone (a null value for the key) so that log-compacted topics drop the key. The event is the unit of truth for everything downstream.

A Debezium update event for one order, simplified. Consumers merge after when op is c/u/r, and delete when op is d.
json
{
  "op": "u",
  "ts_ms": 1772352000123,
  "before": {"id": 9001, "customer_id": 1042, "status": "placed", "amount_cents": 4250, "updated_at": 1772351940000},
  "after":  {"id": 9001, "customer_id": 1042, "status": "paid",   "amount_cents": 4250, "updated_at": 1772352000000},
  "source": {"db": "shop", "schema": "public", "table": "orders", "lsn": 48219044, "txId": 771203}
}

Applying changes downstream

The consumer side turns a stream of events into a table that mirrors the source. Two patterns. Append-only changelog: land every event as a row in a bronze table (cheap, complete history, the audit trail), then derive the current state with a windowed deduplication — latest event per key, excluding deletes. Merge into a mirror: apply events to a table-format table with MERGE, in micro-batches, ordered by commit position, so the silver table is always the source's current state. Most platforms do both: the changelog for history and reprocessing, the merged mirror for queries.

Applying a micro-batch of change events to an Iceberg mirror. Events are deduplicated to the latest per key first, so out-of-order duplicates within the batch cannot flip state backwards.
sql
MERGE INTO lake.silver.orders t
USING (
  SELECT *
  FROM (
    SELECT
      e.after.id            AS id,
      e.after.customer_id   AS customer_id,
      e.after.status        AS status,
      e.after.amount_cents  AS amount_cents,
      e.op,
      e.source.lsn          AS lsn,
      ROW_NUMBER() OVER (PARTITION BY COALESCE(e.after.id, e.before.id) ORDER BY e.source.lsn DESC) AS rn,
      COALESCE(e.after.id, e.before.id) AS key_id
    FROM lake.bronze.orders_changes e
    WHERE e.ingested_at >= TIMESTAMP '2026-03-04 10:00:00'
  ) WHERE rn = 1
) s
ON t.id = s.key_id
WHEN MATCHED AND s.op = 'd' THEN DELETE
WHEN MATCHED AND s.lsn > t.last_lsn THEN UPDATE SET status = s.status, amount_cents = s.amount_cents, last_lsn = s.lsn
WHEN NOT MATCHED AND s.op != 'd' THEN INSERT (id, customer_id, status, amount_cents, last_lsn)
  VALUES (s.key_id, s.customer_id, s.status, s.amount_cents, s.lsn);

Two details make this robust. Keeping last_lsn on the mirror and only applying newer events makes replays idempotent: a batch applied twice changes nothing. Handling deletes explicitly (op = 'd') is the reason CDC was chosen; a mirror that ignores them drifts. For slowly changing dimensions the same events feed a Type 2 history table, with each change becoming a new versioned row and the previous row closed at the event's commit time.

Snapshots, schema changes and restarts

A new connector must capture the existing rows before it can stream changes: the initial snapshot reads each table (as op = 'r' events) at a consistent point and then continues from the log position recorded at the snapshot's start, so nothing between snapshot and streaming is missed. Snapshots of large tables take time and load; incremental snapshot features chunk them and interleave with streaming. Re-snapshotting a single table after a problem is a normal operation, and downstream merges handle it because r events apply like upserts.

  • Schema changes at the source (a new column, a type change) appear in the event schema; with a schema registry in backward mode, additive changes flow through, and downstream tables using on_schema_change or table-format evolution pick them up. Breaking changes need coordination, exactly as the data contract described.
  • Slot growth: if the connector stops, Postgres retains WAL for the slot indefinitely and the disk fills. Monitor slot lag (pg_replication_slots), alert on it, and drop slots for decommissioned connectors.
  • Heartbeats: on quiet databases the slot position does not advance; heartbeat events keep it moving so WAL can be released.
  • Restarts resume from the committed offset with at-least-once semantics; downstream idempotency (the last_lsn guard) absorbs the replayed events.
  • Ordering is per key within a partition; keying topics by primary key preserves per-row order, which is all a mirror needs.
The two queries to run on the source whenever CDC is in place: slot lag, and the WAL it is holding.
sql
SELECT slot_name, active,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)) AS retained_wal
FROM pg_replication_slots;

-- alert if retained_wal exceeds a few GB, or if active is false for more than a few minutes

When CDC is worth it

CDC is the right tool when deletes and every intermediate change matter (finance, inventory, auditing), when latency must be seconds to minutes, when the source cannot tolerate polling, or when many consumers need the same changes (a mirror, a search index, a cache, a fraud model). It is the wrong tool when a nightly snapshot of a small table is enough, when the team cannot operate Kafka and Connect, or when the source database team will not enable logical replication. Managed CDC services and warehouse-native connectors reduce the operational burden and are often the pragmatic middle.

Tip

Start with one important table, land its changelog to bronze, build the merged mirror, and reconcile it against the source nightly (row counts, a checksum of key columns). A mirror that reconciles is one you can put downstream systems on.

Hands-on practice

Mirror a Postgres table with CDC

  1. Run Postgres, Kafka (KRaft), Kafka Connect with the Debezium Postgres connector and a schema registry locally with Docker Compose (Debezium publishes example compose files). Enable wal_level = logical.
  2. Create the orders table, insert 1,000 rows, then create the publication and register the connector with snapshot.mode = initial. Confirm 1,000 op = 'r' events on the shop.public.orders topic.
  3. Run an UPDATE and a DELETE on the source and inspect the resulting events (and the tombstone) with the console consumer.
  4. Write a consumer (Python, or Spark Structured Streaming) that lands events into a bronze Parquet or Iceberg changelog table with ingested_at, then apply the MERGE from the lesson into a silver mirror.
  5. Stop the connector, run 500 more changes including deletes, restart it, and confirm the mirror catches up with exactly the source's row count and no duplicates.
  6. Query pg_replication_slots while the connector is stopped and watch retained WAL grow; then write the alert condition you would use.
  7. Add a nullable column to orders, insert a row using it, and confirm the change flows through the registry and into the mirror.
Cheat sheet

Change data capture & incremental loads — at a glance

Main things to focus on

  • Log-based CDC reads the WAL: every insert, update, delete in commit order, with negligible source load
  • Debezium: replication slot + publication + connector; one topic per table keyed by primary key; op, before, after, source.lsn
  • Land the changelog to bronze (history), MERGE into a silver mirror (current state); guard with last_lsn for idempotency; apply deletes
  • Initial snapshot then stream; re-snapshot is normal; restarts are at-least-once
  • Monitor slot lag and retained WAL; heartbeats on quiet databases; drop dead slots
  • Use CDC when deletes, intermediate changes, low latency or many consumers matter; otherwise batch is simpler

Postgres setup

wal_level = logical; max_replication_slots; max_wal_sendersServer prerequisites (restart)
CREATE ROLE debezium WITH LOGIN REPLICATIONReplication user
CREATE PUBLICATION pub FOR TABLE t1, t2Which tables to capture
ALTER TABLE t REPLICA IDENTITY FULLFull before image on update/delete
SELECT slot_name, active, pg_wal_lsn_diff(...) FROM pg_replication_slotsSlot lag and retained WAL

Connector

plugin.name=pgoutput, slot.name, publication.nameCore Postgres connector settings
snapshot.mode=initial | never | when_neededHow existing rows are captured
table.include.listTables to stream
tombstones.on.delete=trueNull value after delete for compaction
heartbeat.interval.msAdvance the slot on quiet databases
Avro converters + schema registryTyped, evolvable events

Event and apply

op: c | u | d | rCreate, update, delete, snapshot read
before / after / source.lsn / ts_msRow images and ordering position
ROW_NUMBER() OVER (PARTITION BY key ORDER BY lsn DESC) = 1Latest event per key in a batch
MERGE ... WHEN MATCHED AND op='d' THEN DELETEApply deletes explicitly
WHEN MATCHED AND s.lsn > t.last_lsn THEN UPDATEIdempotent replay guard
bronze changelog + silver mirrorHistory and current state

Operations

initial snapshot -> stream from recorded LSNNo gap between the two
incremental snapshot signalRe-capture one table without stopping
alert: retained WAL > N GB or slot inactive > M minPrevent disk exhaustion
backward-compatible schema changes flow; breaking ones coordinateContracts apply
nightly reconciliation: counts + checksums vs sourceProve the mirror is faithful

Common pitfalls

  • Forgetting that a stopped connector holds WAL forever via its slot, until the source disk fills.
  • Applying events without ordering or an LSN guard, so a replay or an out-of-order batch flips a row to an older state.
  • Ignoring delete events, so the mirror keeps rows the source removed — the exact problem CDC was meant to fix.
  • Default REPLICA IDENTITY, so update and delete events lack the before image needed for some consumers.
  • Running CDC for a small reference table that a nightly snapshot would handle with a tenth of the operational cost.
  • No reconciliation, so a silently broken connector is discovered by a wrong report.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →