Course 4 · The Craft

Change Data Capture

Last chapter you moved data in with extracts and loads. But a nightly extract only ever sees a source as it looks right now — it never sees what happened in between. Change Data Capture closes that gap: instead of re-reading the table, you capture every insert, update, and delete the moment it happens. This is how real platforms keep a warehouse in lock-step with a live database — with full fidelity and no history lost.

Setup — do this first

You'll need your mini-griddp repo with Postgres up (docker compose up -d) and DuckDB available, exactly as in Chapter 05. The hands-on section runs entirely in DuckDB by simulating a change feed, so it works on any laptop — no special Postgres configuration required. Keep a terminal beside this page.

The snapshot problem

In Chapter 05 you loaded data the simplest way: run a SELECT * against the source, dump the result into the warehouse. Maybe nightly, maybe with a high-water mark so you only grab new rows. That works — until you ask what it misses. A full nightly snapshot is a photograph taken once a day. Anything that happened between two photographs is simply gone.

Picture our GPU-rental marketplace. The source has a rentals table (who rented what, and at what status) and a prices table (the hourly rate per GPU type). Both change all day long. Now look at what a once-a-day SELECT * can and can't tell you:

Source table, over one day What the nightly snapshot sees ──────────────────────────────── ────────────────────────────── 09:00 rental #501 INSERT (active) 11:00 rental #501 UPDATE → paused ┐ 14:00 rental #501 UPDATE → active │ all three updates collapsed 18:00 rental #501 UPDATE → completed ────┴─► one row: "completed" 22:00 rental #502 INSERT, then DELETE ────► nothing at all — never seen ── snapshot taken here, 23:59 ──────────────────────────────────────▶

Four distinct things went wrong, and each is a real failure mode:

What the snapshot missesWhy it hurts
Intra-day changesRental #501 was paused, resumed, then completed. The snapshot shows only the final state — you can't report "how long was it paused?"
DeletesRental #502 was created and deleted before midnight. A SELECT * can never show you a row that no longer exists. Your warehouse silently still thinks it's there (or never knew it).
Lost history of in-place updatesWhen a price changes from $2.10 to $2.40, the old value is overwritten in the source. Tomorrow's snapshot has $2.40; the $2.10 is gone forever — and so is the question "what did this cost last Tuesday?"
Full-table scansTo find what changed, you re-read the entire table — millions of rows — every run. That hammers the production OLTP database your customers depend on.
⚠ The delete problem is the sharpest one

Incremental extraction with a high-water mark (WHERE updated_at > :last_run) catches new and changed rows cheaply. But a deleted row has no updated_at to compare — it's gone. Query-based methods are structurally blind to deletes. If your source ever deletes rows and you care about correctness, this alone is a reason to reach for CDC.

What CDC is

Change Data Capture (CDC) flips the model. Instead of periodically asking the table "what do you look like now?", you capture every individual change — every insert, update, and delete — as a stream of discrete change events, in the exact order they happened.

Compare the two mental models directly:

SNAPSHOT (query-based) CDC (event-based) ────────────────────── ───────────────── ┌──────────────┐ INSERT #501 ─┐ │ full table │ re-read everything UPDATE #501 │ │ SELECT * │ ──► one row per key UPDATE #501 ├─► ordered stream │ (a photo) │ = latest state UPDATE #501 │ of every change └──────────────┘ DELETE #502 ─┘ (a movie) once a day continuous, as it happens

A snapshot is a photo; CDC is the movie. With the movie you can always reconstruct any photo (replay the changes up to a point in time), and answer questions a photo never could: how often did this row change, what was deleted, what was the value at 2pm last Tuesday. The cost is that you now have to capture and process a stream rather than run one tidy query — which is the rest of this chapter.

Log-based CDC

How do you capture every change without re-querying the table? The clever answer: the database is already writing every change down for you. Every transactional database keeps a write-ahead log (the WAL in Postgres; the binlog in MySQL; the redo log in Oracle). Before any change touches the actual table files, the database appends a record of it to this log — that's how it guarantees durability and can recover after a crash.

That log is a perfect, ordered, complete record of every insert, update, and delete that ever happened. Log-based CDC simply tails that log. A connector — the best-known is Debezium — reads the WAL, decodes each entry into a structured change event, and emits it to a stream (typically Kafka / Redpanda, which you'll meet in Chapter 09).

Postgres Debezium Stream Consumers ───────── ───────── ────── ───────── ┌─────────────┐ ┌──────────────┐ ┌─────────┐ ┌──────────┐ │ tables │ │ reads the │ decode │ topic: │ │ warehouse│ │ ▼ │ WAL │ replication ├─────────►│ rentals ├──────►│ lakehouse│ │ write- ├────────►│ slot, tails │ events │ (Kafka /│ │ search │ │ ahead log │ tail │ the WAL │ │ Redpanda)│ │ index │ └─────────────┘ └──────────────┘ └─────────┘ └──────────┘ every change one row in the WAL ──► one change event in the stream

Why is reading the log so much better than the obvious alternative — polling the table with a query on a timer?

Query-based (polling)Log-based (WAL)
Load on sourceEvery poll runs a real query against live tablesReads the log the DB already writes — near-zero query load
DeletesInvisible — a gone row has nothing to queryCaptured: the WAL records the delete explicitly
OrderingApproximate, inferred from timestampsExact — the log is the order of commits
Intermediate statesMissed between pollsEvery change captured, even three updates a second apart
LatencyAs slow as your poll interval (minutes)Sub-second — events flow as commits land

A single change in the WAL becomes one richly-structured event. Here is what a Debezium-style event for an update to a rental looks like — note that it carries both the before and the after image of the row, plus an op code telling you what kind of change it was:

cdc-event.json
{
  "op": "u",                       // c = create/insert, u = update, d = delete, r = snapshot read
  "ts_ms": 1718900000000,          // when the change was captured
  "source": {
    "db": "griddp",
    "table": "rentals",
    "lsn": 24563120,               // log sequence number — the exact position in the WAL
    "txId": 5512
  },
  "before": {                       // the row as it was (null for an insert)
    "rental_id": 501,
    "gpu_type": "A100",
    "status": "paused"
  },
  "after": {                        // the row as it now is (null for a delete)
    "rental_id": 501,
    "gpu_type": "A100",
    "status": "active"
  }
}
Read an event like a sentence

"At ts_ms, in source.table, operation op changed the row from before to after." For an insert, before is null; for a delete, after is null. The lsn is the row's exact position in the log — it gives you a total order and lets a connector resume from precisely where it left off after a restart.

Handling the change stream

Capturing the stream is half the job; the other half is applying it downstream so your warehouse reflects the source. There are two outputs you usually want from the same stream, and CDC can feed both:

  • Current state — a table that mirrors the source as it is right now. You get this by upserting: an insert or update writes the after row (merge on the key); a delete removes the key.
  • Full history — a record of every version a row ever had. You get this by appending every event to a log table, never overwriting. This is exactly the slowly-changing dimension (SCD2) pattern from Chapter 03 — a CDC stream is the most natural raw material for SCD2 history.
┌──────────────────────────────► CURRENT STATE │ upsert: write `after`, (one row per key, change stream ─────┤ delete: remove key mirrors the source) (c / u / d events) │ └──────────────────────────────► FULL HISTORY append every event (every version ever, (never overwrite) SCD2 from ch.03)

Two details matter when you apply the stream:

ConceptWhat it is & why it matters
TombstoneA delete event (and, in Kafka, a follow-up null-value record) marks a key as gone. Without it, your current-state table would keep a row the source has deleted. The tombstone is how a delete propagates.
Idempotent applyStreams can redeliver an event after a restart. Applying by upsert on the key (merge), not blind insert, means replaying the same event twice yields the same result — safe to re-run.
Order by LSNApply events in log order. If two updates to the same key arrive out of order, the latest lsn must win, or current state ends up stale.

Hands-on: apply a change feed

Configuring Postgres logical decoding and Debezium is a real production setup, but it's heavy for a laptop. The logic of CDC — turning a stream of c/u/d events into current state and history — is the part worth your fingers. So we'll simulate the change feed: a small table of CDC events, exactly the shape Debezium would emit, and write the SQL that applies it. Run this in DuckDB.

duckdb
-- A simulated CDC feed for one rental key (501), in log order.
-- op: c = insert, u = update, d = delete.  lsn = position in the WAL.
CREATE OR REPLACE TABLE cdc_feed (
  lsn      INTEGER,
  op       VARCHAR,
  rental_id INTEGER,
  status   VARCHAR,     -- the value in `after` (NULL for a delete)
  ts       TIMESTAMP
);

INSERT INTO cdc_feed VALUES
  (10, 'c', 501, 'active',    TIMESTAMP '2026-06-21 09:00'),
  (20, 'u', 501, 'paused',    TIMESTAMP '2026-06-21 11:00'),
  (30, 'u', 501, 'active',    TIMESTAMP '2026-06-21 14:00'),
  (40, 'u', 501, 'completed', TIMESTAMP '2026-06-21 18:00'),
  (50, 'c', 502, 'active',    TIMESTAMP '2026-06-21 21:00'),
  (60, 'd', 502, NULL,        TIMESTAMP '2026-06-21 22:00');

First, build current state: for each key, take the latest event by lsn, and drop keys whose last event was a delete (the tombstone).

duckdb
-- latest event per key, then filter out deletes
WITH latest AS (
  SELECT *,
         ROW_NUMBER() OVER (PARTITION BY rental_id ORDER BY lsn DESC) AS rn
  FROM cdc_feed
)
SELECT rental_id, status, ts AS as_of
FROM latest
WHERE rn = 1
  AND op <> 'd'          -- tombstone: a key whose last op is a delete is gone
ORDER BY rental_id;
✓ You should see

Exactly one row: 501 | completed | 2026-06-21 18:00. Key 502 is correctly absent — it was inserted then deleted, so its tombstone removes it from current state. This is the delete the nightly snapshot could never represent.

Now build full history — every version key 501 ever held, with a valid-from / valid-to window. This is SCD2, derived straight from the feed:

duckdb
-- each event opens a new version; the next event's ts closes it
SELECT
  rental_id,
  status,
  ts AS valid_from,
  LEAD(ts) OVER (PARTITION BY rental_id ORDER BY lsn) AS valid_to,
  (LEAD(lsn) OVER (PARTITION BY rental_id ORDER BY lsn) IS NULL) AS is_current
FROM cdc_feed
WHERE op <> 'd'          -- a delete closes the prior version but adds no new one
  AND rental_id = 501
ORDER BY lsn;
✓ You should see

Four history rows for 501: active (09:00→11:00), paused (11:00→14:00), active (14:00→18:00), and completed (18:00→NULL, is_current = true). Every intra-day state the snapshot collapsed is now preserved — you can answer "how long was #501 paused?" (three hours) directly from this table.

From simulation to the real thing

In production the cdc_feed table is replaced by a stream from Debezium, and the current-state query becomes a MERGE / upsert that runs continuously. The shape of the logic — order by LSN, latest-wins, tombstones for deletes, append for history — is identical to what you just wrote. You've done the conceptual core of every CDC pipeline.

Tradeoffs & when to use it

CDC is powerful, but it is not free, and it is not always the right tool. The honest comparison against the simple incremental extraction from Chapter 05:

Incremental extract (ch.05)Change Data Capture
SetupA query and a high-water mark — minutesLogical decoding, a connector, a stream — real infrastructure
Captures deletesNoYes
Captures every intermediate stateNo — only the latest between runsYes — every commit
LatencyBatch (minutes to a day)Seconds — near real-time
Load on sourceA query per runNear-zero (tails the log)
Operational complexityLowHigher — connectors, slots, a stream to run
Best whenAppend-mostly tables, daily reporting is fineDeletes/edits matter, you need fresh data or full history
A rule of thumb

Reach for CDC when at least one of these is true: the source deletes or edits rows and you need correctness; you need low latency (dashboards that must be minutes-fresh); or you need full history for audit or SCD2. If your source is append-only and a daily batch is fine, the simple incremental extract from Chapter 05 is the right, cheaper choice. Don't pay for the movie when a photo will do.

✓ Check yourself

  • Why can a query-based nightly snapshot never capture a deleted row?
  • What is the WAL, and why is tailing it cheaper than polling the table?
  • In a CDC event, what do before and after hold for an insert? For a delete?
  • How does the same change stream produce both current state and full history?
Exercise — Apply three CDC events for one key

You receive three CDC events for customer id = 42, in log order:

  1. lsn 100, op=c, tier="free"
  2. lsn 200, op=u, tier="pro"
  3. lsn 300, op=d (delete)

Show (a) the resulting current-state row(s) for key 42, and (b) the history rows.

solution
CREATE OR REPLACE TABLE cust_cdc (lsn INT, op VARCHAR, id INT, tier VARCHAR);
INSERT INTO cust_cdc VALUES
  (100, 'c', 42, 'free'),
  (200, 'u', 42, 'pro'),
  (300, 'd', 42, NULL);

-- (a) CURRENT STATE: latest event is a delete -> the key is GONE.
WITH latest AS (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY id ORDER BY lsn DESC) AS rn
  FROM cust_cdc
)
SELECT id, tier FROM latest WHERE rn = 1 AND op <> 'd';
--  -> zero rows. The delete tombstones key 42.

-- (b) HISTORY: every version the key ever held (deletes add no new version).
SELECT id, tier,
       lsn AS valid_from_lsn,
       LEAD(lsn) OVER (PARTITION BY id ORDER BY lsn) AS valid_to_lsn
FROM cust_cdc
WHERE op <> 'd'
ORDER BY lsn;
--  -> 42 | free | 100 | 200
--     42 | pro  | 200 | 300   (closed at lsn 300 by the delete)

The key insight: current state shows nothing for key 42 (it was deleted), yet history still proves it existed — it was free, then pro, then gone. A snapshot taken after the delete would tell you customer 42 never existed at all. That difference — preserving what was deleted and how it changed — is the whole reason CDC exists.

Next

You can now move data in and capture every change to it with full fidelity. Next we turn that raw, ingested data into clean, tested, business-ready tables using the modern transformation tool. → Transformation with dbt