Change Data Capture

Change Data Capture (CDC) is the discipline of identifying, capturing, and emitting every row-level change — INSERT, UPDATE, DELETE — that occurs in an Online Transaction Processing (OLTP) database, then delivering those changes as an ordered stream to downstream consumers. The pattern is foundational to nearly every modern data platform: cache invalidation, search-index maintenance, materialized read models for Command Query Responsibility Segregation, analytics replication into a warehouse or lake, multi-database synchronization in Polyglot Persistence architectures, and zero-downtime migrations. Three mechanisms have been used historically — query polling, database triggers, and reading the database’s transaction log — with the last (log-based CDC) winning the modern era because it is the only one that captures every change, in commit order, with effectively zero load on the source database. Tools such as Debezium, AWS Database Migration Service (DMS), Fivetran, and Netflix’s DBLog are essentially log-readers for popular databases. CDC sits at the intersection of databases, distributed logs, and stream processing, so it appears in interviews any time the question involves replicating, indexing, or reacting to data changes without coupling the producing service to every consumer.

1. Plain-Language Definition

The instinctive way to make data flow between systems is to copy it on a schedule: every five minutes, dump the orders table to a CSV and load it into the warehouse. This is batch extract-transform-load (ETL), and it has two fatal weaknesses for modern workloads. First, the latency floor is the schedule interval, so a five-minute batch means downstream systems are at best five minutes behind reality. Second, batch dumps cannot natively distinguish a deleted row from one that was never inserted, so deletes either silently vanish or require expensive full-table comparisons.

Change Data Capture flips the model: instead of periodically asking “what does the table look like now?”, capture every individual mutation as it happens — “row 7 changed from (name='alice', email='a@x.com') to (name='alice', email='a@y.com')” — and emit that mutation onto a stream. Consumers subscribing to the stream learn about each change a few milliseconds after it commits. Deletes are first-class events. Updates carry both before and after images, so consumers can compute deltas. The schedule disappears; the system becomes event-driven.

The “capture” part of the name is load-bearing. CDC does not make the source database emit events on your behalf; it observes the database’s existing record-keeping and translates it into a stream. The cleanest source of truth a database has is its Write-Ahead Log — every committed change is already written there for crash recovery, in commit order, with full before/after data. A log-based CDC system reads that log and republishes its contents in a portable format. Because the log already exists for reasons unrelated to CDC, capturing from it imposes essentially zero overhead on the database’s write path.

2. Mechanism

A CDC pipeline has four logical pieces: a source database producing changes, a capture process that reads the database’s log and serializes change events, a transport (almost always a Distributed Log System Design such as Apache Kafka) that buffers and orders events durably, and one or more sinks (a search index, a warehouse, a cache, another database) that consume the stream and apply the changes. The capture process is the technically interesting part; everything downstream is a normal stream-processing problem.

flowchart LR
    APP[Application] -->|writes| DB[(OLTP Database<br/>Postgres / MySQL / SQL Server)]
    DB -->|WAL / binlog / redo| CAP[CDC Capture Process<br/>Debezium / DMS / DBLog]
    CAP -->|change events| KAFKA[(Kafka Topic<br/>partitioned by primary key)]
    KAFKA --> SINK1[Elasticsearch<br/>Search Index]
    KAFKA --> SINK2[Snowflake<br/>Data Warehouse]
    KAFKA --> SINK3[Redis<br/>Read-Side Cache]
    KAFKA --> SINK4[Another Service<br/>via Kafka Connect]

Diagram: a typical log-based CDC pipeline. The application writes only to the OLTP database; the capture process tails the database’s transaction log and republishes each change to a Kafka topic; consumers subscribe independently. The application has no idea the pipeline exists, which is the central architectural win — adding a new consumer requires zero changes to the producer.

There are three historically important mechanisms for the capture step.

2.1 Query-Based (Polling)

The simplest approach: every row in every CDC-tracked table carries an updated_at TIMESTAMP column, and a poller runs SELECT * FROM orders WHERE updated_at > :high_water_mark on a schedule, advancing the high-water-mark as it consumes batches. Conceptually clean, requires no special database privileges, works against any database that can serve a query.

In practice it has three failures that disqualify it for serious systems. First, deletes are invisible: a row that has been removed will not appear in any future poll because there is no row to return; the consumer must either accept that delete propagation requires periodic full-table comparison (expensive) or be designed to treat absence-after-presence as a delete (fragile). Second, the poll interval defines latency: poll every minute, you are at best one minute behind, and shrinking the interval costs database CPU on every poll. Third, rapid updates within a poll window collapse: if a row is updated three times between polls, only the final state is captured; the intermediate states (which a downstream auditor or event-sourced consumer might need) are lost forever.

2.2 Trigger-Based

Database triggers fire BEFORE or AFTER each INSERT/UPDATE/DELETE and write a copy of the changed row into a separate “changes” or “audit” table. A poller then drains that audit table to the transport. This solves the deletes problem (the trigger fires on DELETE and writes a tombstone row) and reduces the missed-state-collapse problem (each operation produces a row, not just the final state).

The cost is paid by every transaction in the source database. Each write now does two writes — the data row and the audit row — inside the same transaction. The audit table grows without bound (requires periodic truncation, which itself contends with the trigger). Triggers also tend to be schema-coupled — when DDL changes the data table, the trigger and audit table must change in lockstep, and getting that wrong silently breaks CDC. Trigger-based CDC is the dominant approach in legacy enterprise systems (Oracle GoldenGate originally, SQL Server before “CDC tables” became built-in) but is generally avoided in new green-field designs.

2.3 Log-Based (Modern Standard)

Every transactional database already maintains a sequential record of every committed change for crash recovery and replication: PostgreSQL writes to its Write-Ahead Log (pg_wal), MySQL writes to the binary log (binlog), Oracle writes to the redo log, SQL Server has its own transaction log, MongoDB has the oplog.

Log-based CDC reads this stream and reformats it as portable change events. The ergonomic difference from trigger-based capture is enormous: there is no schema modification on the source table, no extra writes per transaction, no audit-table maintenance. The capture process simply opens a long-lived connection to the database’s replication endpoint and consumes a stream that the database is already producing.

The PostgreSQL implementation is exemplary. As of Postgres 9.4 (2014), the database supports logical decoding: a client connects via the streaming-replication protocol, requests a logical replication slot, and receives a continuous stream of decoded WAL records, with each record carrying the table name, the operation type, and the column values (before and after for updates). The pgoutput plugin (built-in since Postgres 10) emits the standard logical-replication binary format; alternatives like wal2json emit JSON. The replication slot is the critical durability mechanism — Postgres tracks how far the consumer has read and refuses to recycle WAL segments that the slot still needs, so a CDC consumer that goes offline for a week resumes exactly where it left off (PostgreSQL docs — Logical Decoding).

MySQL’s binlog has the same shape: enable binlog_format=ROW (so the log records full rows, not just SQL statements), set a positive binlog_expire_logs_seconds, and a CDC consumer connects as a fake replica using the standard MySQL replication protocol, receiving binlog events as they are produced (MySQL docs — Binary Log). Debezium implements this protocol; AWS DMS does too.

Log-based CDC has three properties that make it the modern default.

Zero overhead on the write path: the database is already writing the log; CDC just opens an extra reader. The marginal cost is the capture process’s CPU (decoding records) and the small amount of bandwidth between database and capture process. The transactional fast path on the source database is unmodified.

Captures every change in commit order: nothing collapses, nothing is missed, including deletes. The order is the order in which transactions committed, which is exactly the order necessary to produce a replayable stream.

Transactional consistency at the row level: each event corresponds to exactly one committed row change, and events are emitted in the order the source database committed them. A multi-row transaction produces multiple events with a shared transaction marker; consumers can choose to apply them atomically or per-row depending on their needs.

3. Origins

The term “Change Data Capture” enters the literature in the mid-1990s, principally in the IBM and Oracle data-warehousing communities, as a name for the trigger-based and log-based replication features they were building to keep operational data stores synchronized with reporting databases.

IBM’s DB2 DataPropagator and Oracle’s Symmetric Replication both used log-mining techniques in the early 1990s. Oracle GoldenGate (originally GoldenGate Software, founded 1995, acquired by Oracle in 2009) is widely considered the first commercial CDC product to industrialize log-based capture across heterogeneous source/target databases. The classic textbook treatment of these patterns appears in Kimball and Caserta’s The Data Warehouse ETL Toolkit (2004), where CDC is one of several techniques for “incremental loads” into a warehouse.

The shift from CDC as a database vendor’s replication feature to CDC as a general data-integration primitive came with the rise of Apache Kafka (LinkedIn, open-sourced 2011) and the realization, articulated most clearly by Jay Kreps in his 2013 essay “The Log: What every software engineer should know about real-time data’s unifying abstraction” (Kreps blog post), that a durable totally-ordered log of state changes is the natural integration point for an entire data ecosystem.

CDC is the bridge: it lifts the log out of each database (where each database has its own private format) into a common Kafka topic that any consumer can read. Kreps’s key insight was that the log abstraction unifies database replication, message queues, and stream processing into one substrate, and that CDC is the mechanism by which existing CRUD databases plug into that substrate.

The open-source CDC tooling era began around 2016 with Debezium, a Red Hat project that wraps the native log-reading protocols of Postgres, MySQL, SQL Server, MongoDB, Oracle, and others, emitting change events to Kafka in a uniform schema. Debezium’s design choices — Kafka Connect-based deployment, Avro+Schema Registry for type evolution, snapshot-then-stream bootstrap — became templates the rest of the ecosystem followed.

Netflix published their internal DBLog framework in 2019 (Netflix Tech Blog — DBLog), which extended the model with the ability to take consistent snapshots of the source table interleaved with the live log stream — solving the “how do I bootstrap a brand-new consumer” problem cleanly via watermark-based chunked snapshotting. The DBLog technique was later folded into Debezium as “incremental snapshots.”

Cloud-managed offerings followed: AWS Database Migration Service (originally Amazon DMS, launched 2016), Google Datastream (2021), Confluent Cloud’s managed Debezium connectors (2020). At the time of writing, every major cloud provider has at least one managed CDC service, and most have a managed Debezium-based offering specifically.

4. Worked Example

Consider an e-commerce application backed by a PostgreSQL orders table. The product team needs three downstream consumers: an Elasticsearch index for full-text search across orders, a Snowflake replica for analytics dashboards, and a Redis cache the storefront uses to render the user’s recent orders.

The application writes only to Postgres; nothing else changes. We deploy Debezium’s PostgreSQL connector as a Kafka Connect worker, configured with a logical replication slot named orders_cdc and pgoutput as the decoder plugin. On startup, Debezium takes an initial consistent snapshot of the orders table — it acquires a brief ACCESS SHARE lock, records the current WAL Log Sequence Number (LSN), reads the table’s current rows, and emits each row as a synthetic READ event into the Kafka topic orders.public.orders. Once the snapshot completes, Debezium switches to streaming mode and tails the WAL from the recorded LSN onwards.

A user places an order: the application executes a transaction:

BEGIN;
INSERT INTO orders (id, user_id, total_cents, status) VALUES (777, 42, 9900, 'pending');
UPDATE inventory SET stock = stock - 1 WHERE sku = 'BOOK-1';
COMMIT;

Postgres writes WAL records for both the insert and the inventory update, then the commit record. Debezium’s reader sees the commit and emits two change events to Kafka, in commit order:

// orders.public.orders   key={"id": 777}
{
  "op": "c",
  "ts_ms": 1715260983000,
  "before": null,
  "after": {"id": 777, "user_id": 42, "total_cents": 9900, "status": "pending"},
  "source": {"db": "shop", "schema": "public", "table": "orders", "lsn": 35728336},
  "transaction": {"id": "82173", "total_order": 1, "data_collection_order": 1}
}
 
// orders.public.inventory   key={"sku": "BOOK-1"}
{
  "op": "u",
  "ts_ms": 1715260983000,
  "before": {"sku": "BOOK-1", "stock": 17},
  "after":  {"sku": "BOOK-1", "stock": 16},
  "source": {"db": "shop", "schema": "public", "table": "inventory", "lsn": 35728337},
  "transaction": {"id": "82173", "total_order": 1, "data_collection_order": 2}
}

The op field is c for create, u for update, d for delete, r for read (snapshot). The transaction block tells consumers that both events came from the same source transaction, in case they need to apply them atomically. The lsn is the source database’s WAL position — durable, monotonically increasing, the canonical CDC offset.

Three sink jobs subscribe:

The Elasticsearch sink uses the Kafka Connect Elasticsearch connector. It treats the topic key (the order ID) as the document ID and upserts the after payload into the orders index. On a d event, it issues a delete to the index. New search results reflect the new order within sub-second latency.

The Snowflake sink is the Snowflake Sink Connector. It accumulates events into micro-batches (say, every 60 seconds or every 10,000 events), writes them to a Snowflake stage as Parquet files, and merges them into the orders table using a MERGE on primary key. Slight latency tradeoff for warehouse cost efficiency.

The Redis sink is a small custom consumer that maintains a user:{user_id}:recent_orders sorted set. On a c event, it adds the order; on a u, it updates the cached row; on a d, it removes from the set. The storefront’s “your recent orders” UI reads only Redis.

Now imagine the Snowflake sink crashes for an hour. Because Debezium is keeping the Postgres replication slot open, Postgres holds onto the WAL segments containing all unconsumed changes. When the sink restarts, Kafka delivers the buffered events, the sink processes them, and the warehouse catches up. Critically, Postgres is keeping enough WAL to feed Debezium, and Kafka is keeping enough log to feed the slowest sink — but the application sees none of this.

A subtle but important property of this pipeline is that the application’s transaction is exactly atomic at the source, but only eventually atomic at the sinks. Recall the original transaction inserted into orders and updated inventory. The two events land in two different Kafka topics (one per source table), are consumed by two different sink jobs, and may be applied at slightly different times. A consumer that joins orders and inventory views in Snowflake will, for a brief window, see the order without the corresponding inventory decrement. This is normal eventually-consistent behavior; the operative question is whether downstream consumers can tolerate that window. For analytics, yes; for a read-model that the storefront uses to display inventory, careful design is required (e.g., emit a single composite event from the application’s outbox, or have the consumer wait for the transaction marker).

4.1 The Outbox Pattern as a Companion

A frequent challenge in CDC pipelines is that the events the application wants to publish are not always identical to the row-level changes in the database. An application might want to emit a domain event “OrderShipped(order_id=777, ship_date=…)” that corresponds to a status change on the orders row but carries computed fields and richer metadata. Capturing the row-level update would lose the semantic context.

The outbox pattern solves this. The application writes its domain event to a dedicated outbox table inside the same transaction that mutates the business tables. CDC tails the outbox table specifically and emits its rows as Kafka events. Because both writes are in the same transaction, the event is atomic with the business change — there is no possibility of “the order shipped but the event was lost” or “the event fired but the order was not actually shipped.” After CDC has captured an outbox row, a periodic cleanup job (or a TTL policy) deletes consumed rows.

The outbox pattern is now considered the canonical way to publish events from a CRUD service. It uses CDC as transport plumbing while keeping the event semantics under the application’s control. Debezium ships an “outbox event router” SMT (Single Message Transform) specifically to consume outbox rows and produce semantically named Kafka topics from them.

4.2 Heartbeats and Dead-Letter Topics

Two production patterns deserve mention. First, a heartbeat is a synthetic event the capture process periodically inserts into the stream when no real changes have occurred recently. Without heartbeats, a quiet table looks identical to a stalled pipeline — both produce no events. With heartbeats every (say) 30 seconds, consumers can distinguish “nothing happened” from “the capture process died” and alert appropriately. Postgres logical decoding can also fail to advance the replication slot’s confirmed-flush position when no data is flowing (because there is nothing to confirm), so heartbeats also serve a useful side-purpose of forcing the slot forward.

Second, a dead-letter topic is where Kafka Connect or a custom consumer routes events that fail to apply at the sink — schema mismatches, sink-specific validation failures, transient errors. The dead-letter topic decouples the failure of one event from the entire pipeline; without it, a single bad event halts all downstream progress. Dead-letter handling is a non-negotiable production concern.

5. Variants and Implementations

SystemMechanismNotes
DebeziumReads native logs (WAL, binlog, redo, oplog)Open-source, runs as Kafka Connect or as a standalone library; the de-facto standard for self-hosted CDC.
AWS DMSReads native logs, writes to Kinesis / Kafka / S3 / target DBManaged service, supports heterogeneous targets (Postgres → Aurora, Oracle → Redshift), automatic schema mapping.
Google DatastreamReads native logs, writes to BigQuery / GCS / Pub/SubGCP-managed, primarily targeting BigQuery for warehouse replication.
FivetranMostly polling-based with log-based for selected sourcesHosted, opinionated about destination schema, popular for SaaS-source ELT.
Maxwell’s DaemonMySQL binlog reader, JSON to KafkaLightweight, MySQL-only; popular before Debezium subsumed its niche.
Postgres Native Logical ReplicationBuilt into Postgres 10+Postgres-to-Postgres only; same WAL machinery Debezium uses, exposed without an external broker.
MongoDB Change StreamsReads oplogMongoDB-native API; treats CDC as a first-class server feature.
Netflix DBLogMySQL binlog + chunked snapshottingInternal Netflix tool; key innovation is interleaved snapshot+stream so the snapshot does not block the live stream.
Oracle GoldenGateRedo log miningHeavy enterprise product; pioneered the technique commercially.

The core algorithm is identical across log-based offerings: open the database’s log-replication protocol, decode each record, emit a normalized event. Differences are in the framing: how schemas are captured, how snapshots interleave with live changes, how pause/resume is handled, what target sinks are pre-built.

5.1 Debezium’s Connector Architecture

Debezium deserves a closer look because it has become the reference implementation. The architecture is:

  1. Kafka Connect framework provides the runtime — a clustered worker pool, REST API, offset storage, status tracking.
  2. Debezium’s connector classes plug in per-database: PostgresConnector, MySqlConnector, SqlServerConnector, MongoDbConnector, OracleConnector, Db2Connector, CassandraConnector (different design — reads commitlogs).
  3. Schema generation produces an Avro schema (or JSON Schema, or Protobuf) for each table and registers it with Confluent Schema Registry; downstream consumers deserialize against the registered schema.
  4. Single Message Transforms (SMTs) are pluggable Kafka Connect transformations that modify events before they reach the topic — flattening the Debezium envelope (often desired by sinks), masking sensitive columns, routing events to topics by content.

The connector’s lifecycle is: start → snapshot (read tables under a single transaction at the recorded LSN) → stream (tail the log from that LSN) → on stop, persist the LSN; on restart, resume from the persisted position. The simplicity of this lifecycle is a design strength.

5.2 Cassandra and the Commitlog Problem

A complication worth noting: not every database exposes its WAL via a clean replication protocol. Cassandra writes a per-node commitlog for crash recovery, but exposes no “stream me your commitlog” API. The Cassandra-CDC story therefore involves either tailing the commitlog directly from each node’s filesystem (which Netflix did internally and described in their DBLog post) or enabling Cassandra’s optional CDC feature (since 3.8, 2016) which copies committed mutations to a per-table CDC directory. Both approaches are far less mature than the relational equivalents and remain operational pain points.

DynamoDB’s Streams feature is functionally CDC — every item-level change in a DynamoDB table is published to a stream that consumers can subscribe to. The design is closer to “managed CDC as a database feature” than to log-based capture, but the semantics are the same.

6. Real-World Examples

Stripe described their use of CDC for online schema migrations in their engineering blog post on online migrations. When migrating a column to a new type or table, they dual-write from the application and run a CDC backfill from the old column to the new one, then verify via consistency checks before cutting reads over.

Airbnb built SpinalTap, a MySQL-binlog CDC system that emits to Kafka, used for cache invalidation, search indexing into Elasticsearch, and feeding their derived-data pipeline. It was open-sourced; the design has been described in Airbnb engineering talks.

Shopify uses Debezium-based CDC on their main Postgres shards to feed Elasticsearch product indexes, BigQuery for analytics, and downstream microservices. Multiple Shopify engineering blog posts mention the pattern.

Slack uses MySQL binlog-based CDC (their own framework, not Debezium) to feed message-search indexes and analytics. Discussed in their engineering blog and at re:Invent talks.

Netflix runs DBLog at scale across a polyglot fleet: MySQL, Postgres, Cassandra (Netflix Tech Blog — DBLog). Cassandra CDC is interesting because Cassandra writes a Write-Ahead Log but does not natively expose it as a replication stream the way Postgres does — Netflix instrumented the commitlog directly.

WePay authored several Debezium connectors and described how they replicate Postgres into BigQuery for revenue reporting. The “Debezium book” is largely composed of WePay’s production lessons.

LinkedIn runs Brooklin, an internally-developed CDC platform that ingests changes from Espresso (their distributed document store) and Oracle and emits them to Kafka. Brooklin is open-source and described in detail in LinkedIn’s engineering blog. It pre-dates Debezium and uses a different architecture (per-source connector, no Kafka Connect dependency), illustrating that there is no single canonical CDC implementation pattern.

Uber built Storagetapper to capture MySQL binlogs and emit them to Kafka, feeding their analytics platform. The system is open-source and was described at Strata. Uber’s case is interesting because of the sheer volume (billions of changes per day) and the strict ordering requirements imposed by their financial-reporting use cases.

6.1 Zero-Downtime Migration via CDC

The most operationally important application of CDC is zero-downtime database migration. Suppose a service needs to migrate from MySQL to Postgres, or from one Postgres cluster to another, without service interruption. The classical recipe (described in Stripe’s online-migrations post and many similar engineering blogs) is:

  1. Spin up the destination database empty.
  2. Start CDC from source to destination, beginning with a snapshot of the source’s current state, then streaming changes.
  3. Wait for the destination to catch up to the source (small lag).
  4. Implement dual-writes from the application: writes go to both source and destination, reads still come from the source.
  5. Verify destination is consistent with source via background reconciliation queries.
  6. Cut reads over to destination.
  7. Stop dual-writes; destination is now the primary; CDC stream becomes the rollback path.
  8. Decommission the source after a confidence-building hold-out period.

The CDC stream replaces what used to be “schedule a maintenance window and dump-and-restore.” The operational complexity is real, but the user-facing downtime drops from hours to zero. Every modern operations team that runs a moderately critical service uses some flavor of this pattern.

7. Tradeoffs

DimensionLog-Based CDCTrigger-Based CDCPolling
Source DB loadNegligible (log already exists)Moderate (triggers on every write)Low constant baseline (one query per interval)
Captures deletesYesYesNo (without periodic full scans)
Captures intermediate statesYesYesNo (only the final state per interval)
LatencySub-second (streaming)Seconds (audit-table drain interval)Minutes (poll interval)
Schema-change handlingRequires DDL events from the logTriggers must be re-appliedUsually transparent
Setup complexityReplication slot/binlog config; failure modes nuancedDBA-level effort; trigger managementTrivial
Required privilegesREPLICATION role / REPLICATION CLIENTCREATE TRIGGER + audit table writesPlain SELECT
Heterogeneous source supportPer-database connector; not all DBs have logical replicationUniversal (any DB with triggers)Universal

The key insight is that log-based CDC dominates wherever the source database supports it. Trigger and polling approaches survive only for databases without a usable log-replication protocol, or in environments where granting replication privileges is politically difficult.

A second tradeoff worth naming: schema evolution. Log-based CDC has the harder schema-evolution story because the log records carry only column ordinals, not names — when DDL adds or drops a column, the consumer must learn about the change to interpret subsequent records correctly. Debezium handles this by reading from pg_catalog (or MySQL’s information_schema) when it sees a DDL event in the log, capturing the new schema, and emitting it alongside future records via the Confluent Schema Registry. Polling-based CDC sidesteps the issue because every poll is a fresh SQL query that returns column names.

A third tradeoff is the operational footprint. A CDC pipeline typically requires running and maintaining: the source database with replication enabled, a Kafka Connect cluster (or equivalent worker fleet), a Schema Registry, a Kafka cluster, monitoring for replication-slot lag and Connect-task health, alerts on schema-incompatible changes, and dead-letter handling. None of this exists if you just take a nightly database dump. Teams that adopt CDC are signing up for a non-trivial operational obligation — the pay-off has to justify the ongoing cost.

A fourth tradeoff is debugging difficulty. When a downstream sink shows incorrect data, the question “where did it go wrong?” can involve any of: a bug in the source application, a Debezium connector misconfiguration, a Kafka schema-registry conflict, a sink-side type-mapping bug, or a network blip that caused a missed event. Tracing a single bad row backward through this chain requires good observability at every layer; in practice, most teams under-invest here and pay for it in incidents.

8. Pitfalls

  1. Schema evolution breaks consumers. A new column added to the source table appears in after payloads from that point forward. Older consumers that deserialize events into a strict schema crash. Mitigation: use a schema registry with explicit compatibility rules (backward, forward, or full); publish schema changes through the same change-event channel; treat consumer schema versions as a deployment concern.

  2. Transactional consistency is per-row, not per-transaction. A transaction touching three rows produces three change events, emitted in commit order, but a consumer reading them sees them one at a time. If the consumer materializes a join across those rows it will observe a brief window of inconsistency between event 1 and event 3. Mitigation: emit a transaction-scope marker (Debezium does, in transaction.id) and have consumers buffer until the full transaction is observed, or design downstream views to be eventually consistent.

  3. Backpressure when consumers fall behind. Postgres replication slots that are not advancing prevent WAL recycling, so a stuck CDC consumer can fill the source database’s disk with retained WAL within hours and crash the database. Mitigation: monitor replication-slot lag aggressively (pg_replication_slots.confirmed_flush_lsn versus pg_current_wal_lsn); alert on growth; have a documented “drop the slot” runbook for emergencies (knowing it forces a re-snapshot of every consumer).

  4. Deletes vs tombstones. Naively, a Kafka topic carrying CDC for a table is a changelog — and in changelog-compacted topics, a null value at a key marks the key for compaction (Kafka calls these tombstones). Debezium emits two events for a delete: the actual delete event with op: "d", and (optionally) a tombstone with null value to trigger compaction. Misconfiguring this means deletes either are not propagated to the cache (no tombstone) or the actual delete event is lost (compaction reaped it). Read Kafka log compaction docs and Debezium’s tombstones.on.delete config carefully.

  5. Multi-master / circular replication. If two databases are CDC’d in both directions, an event from A to B reapplies as a write on B, which CDC captures and sends back to A, ad infinitum. Mitigation: CDC consumers must tag their writes (e.g., a replication_origin column) and the capture process must filter out writes that originated from CDC itself. Postgres logical replication has built-in origin tracking; raw CDC pipelines must implement it manually.

  6. Snapshot consistency on bootstrap. A fresh CDC pipeline must capture the table’s current state before it can stream changes — but the table is being written to during the snapshot. A naive snapshot misses changes in the gap between “snapshot started” and “streaming started.” Debezium uses a single transaction with a recorded LSN to bound the snapshot; DBLog uses watermark-based chunked snapshotting that interleaves with the live stream. Either way, the design must explicitly handle the gap, or downstream data is permanently inconsistent.

  7. Out-of-order events in multi-master sources. When the source is itself a multi-master system (Cassandra, Cockroach with multiple writers), per-row events may arrive out of source-of-truth order. Mitigation: events must carry hybrid logical clocks or version stamps; consumers apply last-writer-wins or merge logic. This is genuinely hard and one reason CDC from single-master Postgres/MySQL is far more common than from multi-master systems.

8.5 Operational Concerns and Monitoring

Running a CDC pipeline in production demands a different set of operational habits than running an OLTP database. The bookkeeping for “where is each consumer” lives across several systems — the source database’s replication slot or binlog position, Kafka’s per-partition offsets, each consumer’s checkpoint store — and they must stay coherent.

The single most important metric is end-to-end lag: the wall-clock delta between a change committing in the source database and that change being applied at a sink. This decomposes into three sublatencies: capture lag (how far behind the WAL/binlog the capture process is), broker lag (how long events sit in Kafka before consumers fetch them), and sink lag (how long between fetch and apply). Each must be observed independently. In Debezium, capture lag is exposed via JMX metrics like MilliSecondsBehindSource; Kafka exposes broker-side lag as the difference between the topic’s log-end-offset and the consumer-group’s current-offset; sink lag is application-specific.

The second most important concern is schema-registry hygiene. Debezium emits Avro or JSON Schema-backed records via Confluent’s Schema Registry, which enforces compatibility rules (BACKWARD, FORWARD, FULL) at every schema change. A misconfigured registry can either reject a benign DDL change (breaking the pipeline) or accept a breaking change (corrupting consumers). Production teams treat the registry like a database: backed up, version-controlled, with explicit migration tooling.

A third concern is bootstrap policy. When a brand-new consumer joins, what does it see? The default Debezium behavior is “snapshot first, then stream” — but the snapshot may take hours on a large table, during which the consumer’s view of the world is frozen. Alternatives include “skip snapshot” (consumer sees only changes from now onward, missing the table’s history) and “incremental snapshot” (Debezium’s signal-table-driven mechanism that interleaves chunks of the snapshot with live changes, the same idea as Netflix’s DBLog watermark approach).

A fourth concern is schema-change DDL events. When a table’s schema changes (ALTER TABLE orders ADD COLUMN tax_cents INT), the change appears in the WAL but does not by itself produce row-level events. Debezium captures DDL via pg_catalog lookups when it sees the relevant operation; the schema delta is then emitted to a parallel topic (e.g., dbserver.public) so consumers know how to interpret subsequent rows. Skipping these events is a common pipeline-corruption cause.

9. Common Interview Discussion Points

When CDC comes up in a system-design interview, the strongest answers move quickly past “what is CDC” to demonstrate that the candidate understands its operational semantics. Expect to discuss:

Why CDC over dual-writes. A common alternative is having the application write to both the database and Kafka itself (“dual-write”). The fatal flaw is the absence of an atomic commit across the two — the database may succeed and Kafka fail, or vice versa, leaving the systems permanently inconsistent. CDC cleanly avoids this by treating the database’s commit as the only commit; Kafka events are derived from the durable log after the fact. The pattern is sometimes called the “outbox pattern” when the application writes to a normal table that CDC tails, instead of relying on raw row-level changes.

Exactly-once vs at-least-once delivery. CDC pipelines are at-least-once by default — a consumer crash before checkpointing means events are redelivered. Idempotent sinks (key-based upserts into Elasticsearch, primary-key MERGE into the warehouse) make at-least-once safe in practice; Kafka transactional produce + read-committed consumers can give exactly-once within the Kafka pipeline. Discussing where idempotency lives is the kind of architectural detail that earns credit.

Heterogeneous-target replication. “Replicate Postgres into Snowflake” is a near-canonical CDC use case. The interesting parts are schema mapping (Postgres types vs Snowflake types), micro-batching for warehouse cost, and recovery semantics on snapshot failure. Knowing these trade-offs separates someone who’s used CDC from someone who’s read about it.

CDC versus Event Sourcing Pattern. The two patterns are easily confused because both produce event streams. CDC captures state changes from a database that was designed around CRUD; the application is unchanged. Event sourcing makes events the primary write — the application appends events, and the current state is derived. CDC is what you do when you have a CRUD database and need a stream; event sourcing is what you do when you can redesign the application from scratch. They are sometimes combined: a service that’s CRUD internally but exposes a CDC stream as its public event interface.

The outbox pattern. Closely tied to CDC. When a service needs to publish a domain event (“OrderShipped”) atomically with a database update, it writes the event into an outbox table inside the same transaction; CDC then tails the outbox table and publishes events to Kafka. The two-phase-write problem is solved by piggy-backing on the database’s atomic commit. Bringing this up unprompted in an interview signals you’ve actually worked on these systems.

Bootstrap and re-snapshot operations. Adding a new consumer typically requires snapshotting the current table state and then catching up via the live stream. Discussing how this is done without taking long table locks (chunked snapshots, watermark interleaving per Netflix DBLog) is a strong intermediate-level topic.

9.1 Designing the Schema of CDC Events

A practical question that comes up in interviews and design reviews: what should a CDC event actually contain? The Debezium envelope is the de-facto standard and worth understanding in detail.

{
  "schema": {...},
  "payload": {
    "before": {"id": 777, "status": "pending"},
    "after":  {"id": 777, "status": "shipped"},
    "source": {
      "version": "2.4.0.Final",
      "connector": "postgresql",
      "name": "shop",
      "ts_ms": 1715260983000,
      "snapshot": "false",
      "db": "shop",
      "schema": "public",
      "table": "orders",
      "lsn": 35728336,
      "txId": 82173
    },
    "op": "u",
    "ts_ms": 1715260983050,
    "transaction": null
  }
}

The op codes are: c (create/insert), u (update), d (delete), r (read, used in snapshots), t (truncate, since Debezium 1.4). The before field is null for creates, the after field is null for deletes, both populated for updates. The source block carries the metadata needed to reason about ordering, replay, and provenance — particularly the lsn (or equivalent per-database position marker), which is the canonical resume point.

A common debate is whether the envelope should be flattened. Many sinks expect “key + value” Kafka records where the value is just the row’s new state; the Debezium envelope wraps that in metadata. The “ExtractNewRecordState” SMT flattens the envelope to just the after payload, which makes downstream sinks simpler at the cost of losing the metadata. Production designs typically keep the full envelope on the primary topic and produce a flattened topic via Kafka Streams for sinks that need it.

9.15 What CDC Cannot Do

It is worth being explicit about what CDC is not good for, because misapplications are common.

CDC is a poor fit for request/response synchronous integration. If a service needs an answer from another service in the request path, CDC’s eventually-consistent stream is not the right plumbing — synchronous RPC or a query API is. Trying to use CDC for “look up the current price” is wrong; CDC gives you a stream of price-change events, not a query interface.

CDC is also a poor fit for business-event semantics that are not 1:1 with row changes. If “user upgraded their subscription” is a single business event but corresponds to mutations across five tables (subscriptions, billing, audit_log, etc.), CDC will produce five separate events that downstream consumers must correlate. The outbox pattern (§4.1) is the standard fix; emit the business event into a dedicated outbox table inside the same transaction.

Finally, CDC is a poor fit for tracking application-level state that is not in the database. If state lives in cache, on a queue, or in another service’s memory, CDC of the database will not capture it. The right answer is to either move that state into the database (and CDC it) or instrument the state’s actual owner to emit events.

9.2 Latency Targets in Practice

A few real-world latency numbers help calibrate expectations. Debezium-on-Postgres typical end-to-end latency (commit at source → event in Kafka) is 50–200 ms under normal load on commodity hardware (Debezium docs). Adding a Snowflake sink with 60-second micro-batching brings sink-applied latency to 60–120 seconds. Adding an Elasticsearch sink with default settings is sub-second. These numbers vary widely with hardware, configuration, and load, but the order of magnitude is what to expect.

Latency under sustained high write load (10,000+ TPS on the source) tends to grow because the capture process becomes CPU-bound in record decoding. Mitigation: shard the source database (each shard gets its own connector), or use newer Debezium versions with multi-threaded snapshotting and decoding. A single Debezium task on a single VM can typically sustain 5,000–20,000 events/sec; beyond that, parallelism is needed.

10. See Also