Module P-23·34 min read

Reading the WAL instead of polling, Debezium in practice, ETL vs ELT, schema evolution downstream, and CDC vs the Outbox pattern.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

Module P-23 — Change Data Capture

What this module covers: Why polling updated_at is not a change feed — it misses deletes, misses writes that bypass your application, and loses rows to a race between transaction start time and commit order that no watermark can close. Trigger-based capture and what it costs on the write path. Then log-based CDC: reading the write-ahead log so every change arrives in commit order with no polling load and no application changes. The replication slot as the most reliable way to fill a production disk, and the two Postgres details that silently ruin a stream — REPLICA IDENTITY and unchanged TOASTed columns. Consistent initial snapshots without locking the table. Delivery semantics, per-key ordering, and the referential-integrity problem downstream. ETL versus ELT, decided by where transformation belongs rather than by fashion. And the comparison that matters most architecturally: CDC captures rows, the outbox publishes events, and the hybrid of the two is usually the right answer.


Polling updated_at Loses Rows, and the Reason Is Not Fixable by Tuning

Every team's first change feed looks like this:

ts

It cannot see deletes. A deleted row does not appear in a SELECT, so the downstream copy keeps it forever. Your search index (Module P-20) serves a product that no longer exists; your warehouse counts an order that was voided. The usual patch is soft deletes — a deleted_at column — which works for the rows you remembered to soft-delete and leaves you unable to ever actually remove data, a problem Module O-9 turns into a legal one.

It cannot see changes that bypass the application. A migration script, an operator's manual UPDATE during an incident, an ON DELETE CASCADE, a bulk backfill — none of these bump updated_at unless someone remembered. The downstream copy is then silently, permanently wrong, and the discrepancy is not discoverable without a full comparison.

It races with transaction visibility, and this is the defect no watermark fixes. now() inside a transaction returns the transaction's start time, and rows become visible at commit. So:

text

Commit order is not timestamp order, and a watermark assumes it is. Long transactions make this common rather than rare, and the loss is silent — no error, no gap in a sequence, nothing to alert on. Overlap windows (updated_at > watermark - 5 minutes) reduce the probability by re-reading recent rows, which is why most polling pipelines appear to work: the bug is a function of how long your longest transaction runs, so it fires during incidents and load spikes, precisely when nobody is looking at the pipeline. And it is unfixable in principle — a five-minute overlap loses rows to six-minute transactions.

And it costs the OLTP database real work. Every poll is a query against your production tables, and a poll frequent enough to be fresh is expensive enough to matter (Module P-22). You are paying continuously to ask a question whose answer is usually "nothing changed."

Analogy: monitoring a filing cabinet by re-reading every folder modified since your last visit, using the date each clerk wrote on the front. You miss folders removed entirely, folders a clerk edited without updating the date, and — the subtle one — folders that a clerk began writing before your last visit and only filed after it, which now carry an older date than your bookmark. The alternative is not visiting more often. It is reading the clerks' logbook, where every action is recorded in the order it was actually completed.


Triggers Work, and They Are on Your Write Path

The next attempt puts capture inside the transaction:

sql

This is a genuine improvement and fixes the first three problems outright: deletes are captured, application-bypassing writes are captured because the trigger fires on any write, and there is no watermark race because the change record commits in the same transaction as the change. For a small system it is a perfectly reasonable answer.

Its costs are all on the write path, which is why it does not scale:

  • Write amplification. Every row change becomes two writes plus the WAL for both. Writing to_jsonb(OLD) and to_jsonb(NEW) for a wide row can be several times the size of the change itself.
  • Latency on the hot path. The trigger executes inside your user's transaction, so its cost is in your checkout latency, and a slow trigger is a slow write.
  • A high-churn queue table. order_changes is inserted into constantly and deleted from constantly, which is the bloat pattern from Module P-9 in its purest form — partition it by day and drop partitions, or it becomes the largest and slowest table you own.
  • Maintenance. A trigger per table, kept in step with schema changes, and a DDL that adds a column to orders without updating the trigger produces a silently incomplete capture.
  • Bulk operations become expensive. A backfill updating 10 million rows now writes 20 million rows and can take an order of magnitude longer, which turns a routine migration into an outage.

The essential insight, though, is that the database is already writing a durable, ordered record of every change for its own purposes. Capturing changes a second time is redundant work.


Log-Based CDC: Read the Log the Database Already Writes

Every durable database writes a write-ahead log before it modifies data pages — that is how crash recovery and replication work (Module P-5). The log is, by construction, a complete and correctly-ordered record of every committed change. Log-based CDC reads it.

In Postgres the mechanism is logical decoding: the WAL's physical byte-level changes are decoded into logical row-level events by an output plugin.

sql

A consumer — Debezium, pg_recvlogical, or a client library — connects to the slot and receives a stream:

json

What this buys, and why it is the answer for anything beyond small scale:

PropertyConsequence
Every committed change, including deletesdownstream copies can converge exactly
Commit orderno timestamp race; the log records what actually happened, in sequence
Before and after imagesyou can compute diffs, and detect which field changed
Transaction boundaries and IDsrelated changes can be applied together downstream
No application code involvedmigrations, manual UPDATEs and cascades are all captured
No polling loadthe database is already writing this; you are reading its tail sequentially
Resumable by LSNa consumer that restarts continues from its last confirmed position

The equivalents elsewhere are the same idea: MySQL's row-format binlog, MongoDB's oplog and change streams, Oracle redo logs, SQL Server's own CDC tables.


The Replication Slot Is How You Fill a Production Disk

This is the most important operational paragraph in the module, because it is the CDC incident that actually happens.

A replication slot guarantees the database will retain every WAL segment the consumer has not yet confirmed. That guarantee is unconditional. If the consumer stops — crashes, is scaled to zero, is deleted, or its network path breaks — the WAL accumulates indefinitely until the disk is full, and a Postgres primary with a full disk stops accepting writes. A forgotten slot from an experiment six months ago is a live time bomb; a Debezium connector that fails at 2 a.m. is a production outage by 6 a.m.

It has a second, quieter effect: a logical slot holds back the catalog xmin, so an inactive slot also prevents vacuum from cleaning up, and you get bloat and transaction-ID wraparound pressure alongside the disk growth (Module F-13).

The controls, all of which belong in place before the first slot is created:

sql
  • Alert on retained WAL bytes per slot and on active = false. These are the two numbers that predict the outage.
  • Set max_slot_wal_keep_size (Postgres 13+) so a slot is invalidated rather than allowed to fill the disk. This breaks the consumer, which then needs a re-snapshot — a deliberate, honest trade: lose the stream instead of the database. Choose it consciously, because the default is unlimited.
  • Drop slots you are not using. Tearing down a consumer without pg_drop_replication_slot is the most common way the bomb gets planted.
  • Never create a slot against a database whose disk headroom you have not checked.

Two Postgres specifics that silently degrade the stream

REPLICA IDENTITY decides whether you get a before image. By default Postgres logs only the primary key for UPDATE and DELETE, so before contains just the key and every other old value is unavailable. If a consumer needs old values — to compute a diff, to route on the previous status, to update an aggregate by delta — you need:

sql

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.