Streaming database changes with CDC
Open in the interactive app: practice, quiz, mock interview →In one minute
- Change data capture turns every committed change in a database into an ordered stream of events, so other systems (search indexes, caches, warehouses, other services) can follow the database without the application writing to them twice.
- The robust way is log-based: read the database's own replication log (Postgres WAL, MySQL binlog, MongoDB oplog). It sees every insert, update and delete, in commit order, including changes made by scripts and migrations, at almost no cost to the write path.
- The stream carries row images, not intent. A
uevent saysstatuswent frompendingtopaid; it does not say why. Use an outbox table when consumers need business events. - The hard parts are not reading the log. They are the initial snapshot and its handoff to the stream, keeping per-key order through partitioning, schema changes, and log retention when a consumer stalls.
- Delivery is at least once. Consumers must be idempotent, usually by comparing the log position or a row version.
How it works
Three ways to capture changes
Query-based CDC polls SELECT * FROM t WHERE updated_at > :last. It needs no privileges, but it misses deletes, collapses several updates between polls into one, and skips rows whose transaction committed after the poll with an older timestamp. Shopify ran exactly this for years; to protect the source databases it polled each table at most hourly, so data was always at least an hour old and hard deletes never appeared (Shopify).
Trigger-based CDC copies each change into a shadow table from a trigger. It catches deletes and intermediate states, but doubles the write work inside every transaction.
Log-based CDC reads the log the database already writes for crash recovery and replication. The write path is untouched and the order is the database's own commit order. This is what Debezium, AWS DMS, Google Datastream and Netflix's DBLog do (DBLog paper), and what this lesson focuses on.
Anatomy of a change event
A typical log-based event carries an operation (c create, u update, d delete, r read from a snapshot), the row's before and after images, and source metadata: table, transaction id, commit timestamp and log position (an LSN in Postgres, a binlog file and offset or GTID in MySQL). The event key is the row's primary key. After a delete, Debezium also emits a tombstone by default (tombstones.on.delete), a record with the key and a null value, so Kafka log compaction can eventually drop every record for that key (Debezium docs).
Snapshot, then stream
A new consumer needs the current state, not just future changes, but the log only goes back as far as retention allows. So CDC starts with a snapshot and switches to the stream. The switch is where data is lost or duplicated if done carelessly: rows changed during the snapshot must not be missed, and stale snapshot values must not overwrite newer streamed ones.
The classic method records a log position, takes a consistent snapshot as of that position, emits every row as r, then streams from the position. On a 2 TB table that means one transaction open for hours while the log piles up; Shopify found MySQL snapshots held read locks for their whole duration and took hours on large tables (Shopify). The watermark method from Netflix's DBLog, also used by Debezium's incremental snapshots, instead reads the table in primary-key chunks interleaved with the stream, so it can pause, resume, and re-snapshot one table while the stream keeps flowing (Debezium blog).
Ordering and partitioning
The log is totally ordered, but nobody consumes it as one sequence at scale. Events are partitioned, usually by primary key, so that all changes to one row land in one partition and stay in order, while different rows are processed in parallel. Order across rows, or across tables, is lost unless you deliberately keep it, for example by routing a whole aggregate (an order and its line items) by the aggregate's id.
This is the ordering and duplicates problem shape, which recurs wherever one ordered source feeds parallel workers, such as an agent's tool-call log replayed into several evaluators: partition by the entity whose order matters, and make workers tolerate redelivery.
Down the stack
Layer 1The contract
A log-based CDC pipeline promises that every committed change appears in the stream, that aborted changes never do, and that changes to one primary key appear in commit order. Delivery is at least once: after a crash, the connector resumes from its last acknowledged position and re-emits what follows. It does not promise that a consumer sees a transaction atomically, nor order across keys once events are partitioned. Latency is usually well under a second, with no upper bound when something stalls.
Layer 2The mechanism
In PostgreSQL, CDC requires wal_level = logical. A replication slot records how far the consumer has confirmed (confirmed_flush_lsn) and how much WAL must be kept to resume (restart_lsn). An output plugin (pgoutput, built in and used with publications, or wal2json) turns WAL into logical changes. WAL records of concurrent transactions are interleaved, so the decoder keeps a reorder buffer per transaction and emits a transaction's changes only when its commit record arrives, in commit order; transactions larger than logical_decoding_work_mem (64 MB by default) spill to disk under pg_replslot (Postgres docs). Since Postgres 14 an output plugin that supports streaming can instead send the largest in-progress transaction in blocks before it commits, followed later by a stream commit or a stream abort, so the consumer must buffer per transaction and be ready to discard (Postgres docs). REPLICA IDENTITY controls the before image: DEFAULT logs only the old primary key for updates and deletes, FULL logs the whole old row at the cost of larger WAL.
MySQL needs binlog_format = ROW and usually binlog_row_image = FULL, with the position tracked as a GTID set; MongoDB change streams use a resume token. Debezium commits its source position to Kafka Connect's offsets topic after writing the events, which is exactly why delivery is at least once.
DBLog's watermark algorithm: write a low watermark row to a small table, which puts a marker in the log; select the next chunk by primary key; write a high watermark row. While consuming the log, collect keys changed between the markers. When the high watermark arrives, drop chunk rows whose key changed in that window (the log event carries a newer state) and emit the rest at that point. No locks, no long transaction, and every row arrives either from the chunk or in a newer log event.
Layer 3The machine
A slot holds back WAL removal, and WAL is generated in 16 MB segments regardless of whether anyone reads it. A primary writing 30 MB of WAL per second retains about 2.6 TB per day if the slot's consumer stops, which fills the disk and stops the database. Since Postgres 13, max_slot_wal_keep_size caps this by invalidating the slot, which forces a re-snapshot but protects the primary; its default is still -1, unlimited (Postgres docs). Postgres 18 adds idle_replication_slot_timeout to invalidate slots that no connection has used for a set time, also off by default (Postgres 18 release).
Failover used to be the other silent killer: logical slots lived only on the primary, so after promoting a standby the connector had no slot and had to re-snapshot. Postgres 17 can synchronise failover-enabled logical slots to a standby (failover on the slot, sync_replication_slots on the standby, plus a physical slot and hot_standby_feedback) (Postgres docs), and Debezium exposes this as slot.failover for Postgres 17 and later (Debezium docs). Every slot decodes the full WAL stream on its own, so five slots cost five times the decoding CPU on the primary, even if each publication covers one small table.
Decoding costs a few microseconds per change, but without streaming a 10 million row UPDATE produces nothing until its commit, then spills gigabytes to pg_replslot and arrives downstream as one burst. In a healthy pipeline end-to-end latency is tens to hundreds of milliseconds, dominated by Kafka producer batching (linger.ms) and consumer polling rather than by the database.
Why it's built this way
- Applications that write to a database and a second system cannot make both writes atomic, so the database's own log becomes the single source of truth for what changed.
- The log exists for crash recovery and replication anyway, so reading it adds no work to the write path and captures every writer, including ones the application team does not control.
- Logs are interleaved by time, not grouped by transaction, so decoders buffer per transaction and emit at commit to hide aborted and in-flight work.
- Logs cannot be kept forever, so new consumers start from a snapshot; long snapshots hold resources, so watermark-based chunking replaces one huge read with many small ones.
- One ordered log cannot be consumed by one worker at scale, so it is partitioned by key, keeping the only order most consumers need.
Diagram
flowchart LR
App["App writes"] --> DB["Postgres primary"]
DB --> WAL["WAL segments"]
WAL --> Slot["Replication slot (restart_lsn)"]
Slot --> Dec["Decoder: reorder buffer, emit at commit"]
Snap["Chunk select between watermarks"] --> Merge["Drop chunk keys changed in window"]
Dec --> Merge
Merge --> K["Kafka topic, partitioned by primary key"]
K --> S["Search indexer"]
K --> C["Cache invalidator"]
K --> W["Warehouse loader"]
S -.->|"ack offset"| KWorked example
An orders database receives 20,000 row changes per second at peak and generates about 25 MB of WAL per second. Three consumers need the data: an Elasticsearch index, a Redis cache invalidator and a warehouse loader.
Event volume: with JSON before and after images, events average about 900 bytes, so the topic receives 18 MB/s. Twelve partitions keyed by order_id let each consumer run twelve workers, each handling about 1,700 events per second, comfortably within what a single indexer thread can bulk-load.
Bootstrapping the index: the orders table has 400 million rows. A watermark snapshot with 10,000-row chunks reads about 40,000 rows per second without disturbing the primary, so the snapshot takes roughly 2.8 hours. During that time the stream keeps flowing. If a chunk covering orders 1,200,000 to 1,210,000 is selected while order 1,204,511 is updated, the update appears in the log between the watermarks, the chunk's copy of that row is dropped, and the indexer gets the newer version from the log event instead.
The failure: the connector's Kafka credentials expire on a Friday night and nobody notices for 6 hours. The slot holds 25 MB/s times 21,600 seconds, about 540 GB of WAL. The primary's volume has 400 GB free. Without a cap, the database stops accepting writes around hour 4.5. With max_slot_wal_keep_size = 200GB the slot is invalidated after about 2.2 hours, the primary stays up, and the team re-snapshots on Monday. Alerting on slot lag at 20 GB would have paged someone after about 13 minutes.
In the wild
- Shopify, 2021: from hourly queries to the binlog. Shopify's query-based tool, Longboat, polled each table at most hourly to protect the source databases, so data was always an hour stale and hard deletes and updates that skipped
updated_atwere lost. The replacement runs about 150 Debezium connectors over more than 100 MySQL shards, averaging 65,000 records a second during Black Friday Cyber Monday 2020 with peaks of 100,000, p99 under 10 seconds from insert to Kafka, and over 400 TB of CDC data. The open problems were snapshots that held read locks for hours, schema changes breaking consumers, and records tens of MB in size (Shopify). - Zalando, 2025: WAL growth on quiet databases at scale. Zalando runs hundreds of Postgres logical replication streams with embedded Debezium across more than 100 Kubernetes clusters. Their biggest operational problem was WAL piling up on low-activity databases whose slots only advance when watched tables change, even with heartbeats. The fix let the JDBC driver confirm progress on keepalives, and they upstreamed
lsn.flush.modeandoffset.mismatch.strategyinto Debezium 3.4 so a restart after the slot moves ahead of the stored offset no longer refuses to start (Zalando).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Log-based CDC | Every change and delete, commit order, no write-path cost | Needs log access, slot and retention operations, row-level events | Feeding indexes, caches and warehouses from an existing database |
| Trigger-based | Works without log access, captures deletes | Extra write in every transaction, triggers to maintain | Small tables on databases without logical decoding |
| Query polling | No privileges, trivial to start | Misses deletes and intermediate states, timestamp races | Low-stakes batch sync, append-only tables |
| Outbox plus CDC | Business-level events with atomic write | Application must write outbox rows | Other services need intent, not row diffs |
| Dual write in app | No extra infrastructure | Diverges on any partial failure | Never for data that must stay consistent |
Pitfalls in production
- Slots that stop advancing. A slot left by a decommissioned consumer retains WAL until the disk fills. So can a healthy connector on a quiet database: WAL is shared by every database on the server, but a slot only advances when its captured tables change. Alert on slot lag in bytes, set
max_slot_wal_keep_size, enable connector heartbeats (heartbeat.interval.ms), and drop slots as part of decommissioning. - Exposing the schema as an API. Consumers depend on column names, so a rename breaks three teams. Use a schema registry with compatibility rules, or publish curated events through an outbox.
- Missing before images. With
REPLICA IDENTITY DEFAULT, a delete carries only the primary key, so a consumer cannot tell which tenant the row belonged to. SetFULLwhere the old row matters. - Partitioning by the wrong key. Keying order line items by
line_idlets an item's update overtake its parent order's insert. Key by the aggregate id when cross-row order matters. - Assuming exactly once. After a connector restart, events since the last committed offset are replayed. Make consumers apply events conditionally on LSN or version.
- Giant transactions. A backfill updating 50 million rows in one transaction arrives as one burst at commit. Batch backfills into small transactions.
Leadership lens
- Decide who owns the CDC platform (connectors, slots, schemas) and who owns each topic's contract. Without that, the database team cannot change a column without a cross-team incident.
- Ask which consumers need row changes and which need business events. Pushing raw table changes across team boundaries couples everyone to one schema.
- Put slot lag, connector lag and snapshot progress on the same dashboard as primary disk usage, and have a runbook for invalidating a slot and re-snapshotting.
- Budget for snapshots: every new consumer, schema change or slot loss means re-reading large tables, so test how long a full snapshot takes before you need it.
Check yourself
Why can't
SELECT ... WHERE updated_at > :lastreliably capture all changes even if every writer setsupdated_at?Show answer
Deletes leave no row to find, several updates between polls collapse into one, and a long transaction can commit after the poll with an
updated_atolder than the high-water mark, so it is skipped forever.In the watermark snapshot, why is it safe to drop a chunk row whose key changed between the low and high watermarks?
Show answer
That change appears as a log event, which carries a state at least as new as anything the chunk could have read, so the consumer still ends with the latest value.
A consumer applies events from 12 partitions in parallel. What ordering does it still have?
Show answer
Order per partition, so per primary key if events are keyed by it. It has no order across keys or tables, and it may see part of a multi-row transaction before the rest.
Why does a stopped consumer threaten the source database in Postgres?
Show answer
Its replication slot prevents WAL removal past
restart_lsn, so WAL accumulates on the primary's disk until the consumer resumes, the slot is dropped, ormax_slot_wal_keep_sizeinvalidates it.
Sources and further study
Primary
- Kleppmann, Getting data out of databases: a surprisingly tricky problem (2015): the argument for treating the database's change stream, not snapshots or dual writes, as the integration point.
Systems docs
- PostgreSQL: Logical Decoding Streaming of Large Transactions: the streaming callbacks and what consumers must do on a stream abort.
- PostgreSQL 17 release announcement: failover control for logical replication and slots that survive major upgrades.
- AWS DMS: ongoing replication (CDC): how a managed service reads redo logs, binlogs and Postgres slots, and why it gives no latency SLA.
- Google Cloud Datastream overview: a serverless CDC and replication service aimed mainly at replicating operational databases into BigQuery.
Books
- Designing Data-Intensive Applications (1st edition), Martin Kleppmann, chapter 11 "Stream Processing", section "Databases and Streams": CDC, initial snapshots, log compaction and event sourcing.
- Designing Data-Intensive Applications (2nd edition), Martin Kleppmann and Chris Riccomini, chapter 12 "Stream Processing", section "Databases and Streams": the updated treatment of the same material.
Other curricula
- Azure Architecture Center: Transactional Outbox with Azure Cosmos DB: uses the Cosmos DB change feed, with per-partition ordering and leases, as the CDC half of an outbox.
Watch
- Change Data Capture and Debezium with Gunnar Morling (The Geek Narrator): the former Debezium lead on log-based CDC, snapshots and failure modes.
- Apache Kafka and Debezium (Red Hat Developer, DevNation Tech Talk): a live demo of streaming database changes into Kafka.
Free resources
- DBLog: A Watermark Based Change-Data-Capture Framework (Netflix, arXiv): the watermark snapshot algorithm in full.
- Incremental Snapshots in Debezium (Debezium blog): how the DBLog idea was implemented in a widely used connector.
- Debezium connector for PostgreSQL (Debezium docs): event structure, snapshot modes, replica identity and slot handling.
- Logical Decoding Concepts (PostgreSQL docs): slots, output plugins and exported snapshots.
- Replication settings (PostgreSQL docs):
max_slot_wal_keep_sizeand the other knobs that protect the primary. - Change Streams (MongoDB docs): the same idea over the oplog, with resume tokens.