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.
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:
Four distinct things went wrong, and each is a real failure mode:
| What the snapshot misses | Why it hurts |
|---|---|
| Intra-day changes | Rental #501 was paused, resumed, then completed. The snapshot shows only the final state — you can't report "how long was it paused?" |
| Deletes | Rental #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 updates | When 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 scans | To find what changed, you re-read the entire table — millions of rows — every run. That hammers the production OLTP database your customers depend on. |
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:
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).
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 source | Every poll runs a real query against live tables | Reads the log the DB already writes — near-zero query load |
| Deletes | Invisible — a gone row has nothing to query | Captured: the WAL records the delete explicitly |
| Ordering | Approximate, inferred from timestamps | Exact — the log is the order of commits |
| Intermediate states | Missed between polls | Every change captured, even three updates a second apart |
| Latency | As 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:
{
"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"
}
}"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
afterrow (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.
Two details matter when you apply the stream:
| Concept | What it is & why it matters |
|---|---|
| Tombstone | A 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 apply | Streams 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 LSN | Apply 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.
-- 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).
-- 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;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:
-- each event opens a new version; the next event's ts closes it.
-- Window over ALL events first (deletes included), then drop the delete rows:
-- that way a delete closes the prior version but adds no new one.
WITH versions AS (
SELECT
*,
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
)
SELECT rental_id, status, ts AS valid_from, valid_to, is_current
FROM versions
WHERE op <> 'd'
AND rental_id = 501
ORDER BY lsn;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.
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 | |
|---|---|---|
| Setup | A query and a high-water mark — minutes | Logical decoding, a connector, a stream — real infrastructure |
| Captures deletes | No | Yes |
| Captures every intermediate state | No — only the latest between runs | Yes — every commit |
| Latency | Batch (minutes to a day) | Seconds — near real-time |
| Load on source | A query per run | Near-zero (tails the log) |
| Operational complexity | Low | Higher — connectors, slots, a stream to run |
| Best when | Append-mostly tables, daily reporting is fine | Deletes/edits matter, you need fresh data or full history |
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
Answer from memory before opening each one.
-
A teammate adds
WHERE updated_at > :last_runto the nightly extract and says the warehouse now matches the source. Finance later finds 40 rentals in the warehouse that the product database doesn't have. What happened, and why can no change to that query fix it?Show answer
Those rentals were deleted at the source. A query can only return rows that exist, so a deleted row has no
updated_atleft to compare — query-based extraction is structurally blind to deletes, however clever the predicate. You need the delete recorded somewhere: the WAL (log-based CDC) or a soft-delete flag in the source. See The snapshot problem. -
The database writes its log whether or not anyone reads it. Why does that make log-based CDC cheaper for the source than polling every minute — and what two things does the log give you that timestamps can't?
Show answer
Polling runs a real query against live tables on every tick; tailing the WAL reads a file the database already produces for durability, so it adds almost no query load. The log also gives an exact commit order (the LSN) and every intermediate state, where timestamps give approximate order and only the latest state between polls. See Log-based CDC.
-
An event arrives with
"before": null. Another arrives with"after": null. What is each one, and what must your current-state table do with the second?Show answer
before: nullis an insert (op = c) — there was no prior row.after: nullis a delete (op = d), and current state must remove that key; if you ignore it, the warehouse keeps a row the source no longer has. See the event anatomy and tombstones. -
After a connector restart, events
lsn 30andlsn 40for rental #501 are delivered a second time — and40arrives before30. Your apply step does a blindINSERTper event. What goes wrong, and which two rules from this chapter prevent it?Show answer
Blind inserts duplicate the rows, and applying 30 after 40 leaves #501 as
activewhen the source sayscompleted. The fixes are idempotent apply — upsert/merge on the key, so replaying an event changes nothing — and order by LSN, latest wins, so a stale event can never overwrite a newer one. See Handling the change stream. -
In the history query, the window function runs over every event and the
op <> 'd'filter comes afterwards. Suppose you moved that filter into the sameSELECTas theLEAD. For a key whose last event is a delete, what would the history wrongly claim?Show answer
WHEREruns before window functions, so the delete row would be gone beforeLEADlooked for "the next event". The last real version would getvalid_to = NULLand look current forever — history would say a deleted customer is still onpro. Windowing first, filtering second, is what lets the delete close the final version. Try it on the exercise below. -
The source table behind
dim_gpu— the list of GPU models — has 12 rows, changes about twice a year, never deletes, and feeds a daily report. A colleague wants it on Debezium "for consistency" withrentals. What do you recommend, and what fact would change your answer?Show answer
Keep the simple extract from Chapter 05: no deletes, no latency need, and a full re-read of 12 rows costs nothing — CDC would add a connector and a stream to operate for no gain. The answer flips if you need the history of those rows (say, what a GPU type's spec was when a rental was priced) or if rows start being deleted. See Tradeoffs.
Exercise — Apply three CDC events for one key
You receive three CDC events for customer id = 42, in log order:
lsn 100, op=c, tier="free"lsn 200, op=u, tier="pro"lsn 300, op=d(delete)
Show (a) the resulting current-state row(s) for key 42, and (b) the history rows.
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).
-- LEAD runs over all events, THEN the delete row is dropped - so the delete still closes 'pro'.
WITH versions AS (
SELECT *, LEAD(lsn) OVER (PARTITION BY id ORDER BY lsn) AS valid_to_lsn
FROM cust_cdc
)
SELECT id, tier, lsn AS valid_from_lsn, valid_to_lsn
FROM versions
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.
Takeaway
- A snapshot shows state at the moment of the read: intra-day changes collapse, history is overwritten, and a deleted row is gone.
- Query-based extraction cannot see deletes, because a deleted row has no
updated_atto compare against. - Log-based CDC tails the database's write-ahead log, so it captures every insert, update and delete in commit order with almost no query load on the source.
- A change event carries the operation, the row before, the row after and the log position, which is what lets a connector resume exactly where it stopped.
- Apply the stream by upserting the
afterrow for current state, removing the key on a delete, and appending every event for history. - Apply in log order and by key, so a replayed or out-of-order event cannot leave the table stale or duplicated.
- Use CDC when the source edits or deletes rows, when you need minutes-old data, or when you need full history; for an append-only table a daily incremental extract is the simpler answer.
Data now arrives with full fidelity, so the next chapter turns it into clean, tested, business-ready tables. → Transformation with dbt
Further reading
- PostgreSQL: Write-ahead logging — PostgreSQL docs, accessed 2026. Why the log exists and why it is a complete, ordered record of changes.
- PostgreSQL: Logical decoding concepts — PostgreSQL docs, accessed 2026. Replication slots and output plugins, the mechanism CDC connectors read.
- Debezium connector for PostgreSQL — Debezium docs, accessed 2026. The shape of change events and the operational limits of a Postgres connector.
- Debezium tutorial — Debezium docs, accessed 2026. A runnable end-to-end example from database to Kafka topic.