Home › High-Level Design › Change Data Capture (CDC)
C

Change Data Capture (CDC)

Ever been asked "how do you keep your search index in sync with your database?" CDC is the answer the interviewer is fishing for. It sounds simple on the surface and then quietly eats a week of your life in production. Get the mental model right and a whole class of data-integration problems stops looking scary.

High-Level DesignAdvanced11 min read

If you remember five things

The whole answer in one glance. Everything below is the defense of these five calls.

  1. One writer, one log, many readers. The app writes only to the database. Everyone else subscribes to the stream of changes, so the write path stops being a distributed transaction across five systems.
  2. Log-based beats query-based. Reading the WAL or binlog sees deletes, captures exact commit order, and barely touches the primary. Polling an updated_at column is the duct-tape version.
  3. At-least-once, not exactly-once. The connector can replay after a crash, so every consumer must be idempotent. Claiming exactly-once with no idempotency is a tell.
  4. Snapshot then stream. An existing table has no history in the log, so you take a consistent snapshot, then switch to streaming from the log position captured at snapshot time. The handoff is the hard part.
  5. Eventual consistency is the price. There is always lag between commit and downstream, usually sub-second, minutes under load. Design for read-your-writes gaps or you will get bug reports that are not bugs.

1The core idea#

CDC turns every insert, update, and delete into a stream of events other systems can consume. That is the whole thing.

  • Your database already knows what changed.
  • CDC is about getting that knowledge out, in order, without hammering the database and without missing anything.

The mental model I give juniors. Your primary DB is the source of truth, but everyone wants the same data shaped differently:

  • A search index wants it tokenized for full-text.
  • A cache wants it hot.
  • A warehouse wants it cheap and columnar.
  • A fraud service wants it the instant a row changes.

The naive fix is to make the app write to all five places. That is where it falls apart:

  • Your write path is now a distributed transaction across five systems.
  • When the third one times out, you are left guessing what actually happened.
  • Five dependencies at 99.9 percent availability each put your write path at roughly 99.5 percent, about 3.6 hours of downtime a month you did not have before.

CDC flips this.

  • The application writes to one place: the database.
  • Everyone else subscribes to the stream of changes.
  • The database becomes the single writer, and the change log becomes the contract.
  • This is the "outbox" idea taken to its logical end: the log of what happened is the integration layer.
Why the log is already there

Most databases already keep a durable, ordered log of every change for their own survival. Postgres has the write-ahead log (WAL), MySQL has the binlog, MongoDB has the oplog. They exist so the DB can recover after a crash and so replicas can follow the primary. CDC just taps that same log and hands it to you. You are not adding a new source of truth, you are reading one that was always running.

2How it works#

There are two families of CDC, and the difference matters a lot in interviews.

Query-based CDC polls the database.

  • You add an updated_at timestamp (or a monotonic version column) to each table.
  • A job runs every few seconds asking "give me everything where updated_at > last_seen."
  • Upside: simple to build, works on any database, no special permissions.
  • Downside: it cannot see deletes (the row is gone, so your query cannot find it).
  • Downside: it adds query load to the primary.
  • Downside: two rows changing in the same millisecond can slip past, depending on your boundary logic.
  • Verdict: the duct-tape option.

Log-based CDC reads the transaction log directly.

  • A connector pretends to be a replica and asks the DB to stream its WAL or binlog.
  • It decodes each change into a structured event: table, operation, before-image, after-image, transaction ID, log position.
  • It sees deletes, captures the exact order the database committed in, and puts almost no extra query load on the primary.
  • This is what you want for anything serious. Debezium is the open-source tool most teams reach for.

Here is the shape of a typical log-based pipeline.

flowchart LR A[App] --> B[(Primary DB)] B --> C[CDC Connector] C --> D[Kafka Topic] D --> E[Search Indexer] D --> F[(Warehouse)] D --> G[(Cache)]
The one writer is the Primary DB. The connector is the only thing reading the log, and the Kafka topic is the fan-out point where the search indexer, warehouse, and cache each consume independently at their own pace.

How the connector stays correct.

  • It writes one message per row change into a Kafka topic (usually one topic per table).
  • It tracks its position in the log (the WAL offset or binlog coordinate).
  • It commits that offset only after the change is safely in Kafka.
  • If it crashes, it restarts from the last committed offset and replays.
  • That replay is exactly why CDC is at-least-once by default, not exactly-once. More on that in pitfalls.

The snapshot is the mechanic that trips people up.

  • Turn on CDC for an existing table of ten million rows and the log only has recent changes, not history.
  • So the connector takes an initial snapshot: a consistent read of the whole table.
  • Then it switches to streaming from the log position captured at snapshot time.
  • Getting that handoff right, no gaps and no duplicates, is the hard part of every CDC tool.

3A worked example with real numbers#

Let me make this concrete with a system I have actually built: keeping an Elasticsearch order-search index in sync with a Postgres orders table.

The numbers.

  • The orders table holds 50 million rows and takes 400 GB of disk.
  • At peak the app commits 2,000 order writes per second (inserts plus updates plus deletes).
  • Each row change decodes to a CDC event of roughly 1.5 KB once you include before-image and after-image.
  • So the stream runs at about 2,000 x 1.5 KB = 3 MB per second, or around 260 GB per day of change events through Kafka.

Sizing the pieces.

  • Kafka topic. One topic, keyed by order_id, 12 partitions. At 3 MB/s a single partition on commodity hardware eats this without sweating, but 12 lets me run up to 12 parallel indexer consumers and gives headroom for a 5x sale-day spike. Retention set to 7 days, so 7 x 260 GB is about 1.8 TB on the brokers. That buys me a full week to replay if an indexer goes bad on a Friday.
  • Initial snapshot. 50 million rows is too big for a blocking snapshot. At chunked reads of 10,000 rows per batch and roughly 20,000 rows/second off a read replica, the backfill takes about 50M / 20,000, which is around 42 minutes, running alongside the live stream via incremental snapshotting so the primary never locks.
  • Lag budget. End to end, commit on Postgres to visible in Elasticsearch, I target under 2 seconds at p99. In steady state it sits around 300 to 500 ms. During the snapshot or a sale spike it can climb to 10 to 30 seconds, and I alert if it crosses 60 seconds.
  • Indexer throughput. Each consumer batches 500 events and bulk-writes to Elasticsearch. At 2,000 events/second I need the fleet to clear 2,000/second steady, so 4 consumers each doing 500/second gives me 2x headroom.
7 days
The number that makes CDC worth itKafka retention. A bad mapping change corrupts the index on a Tuesday, I reset the consumer group offset to 7 days ago and rebuild from the stream, with zero load on the primary. Older than 7 days, I re-run the 42-minute snapshot. Either way the fix is an offset reset, not a database migration project. That replayability is the entire reason I would not dual-write this.

4Trade-offs#

The decisions you actually have to defend:

Decision Option A Option B What you give up
Capture method Query-based polling Log-based streaming A: misses deletes, adds load. B: needs replication access, more ops
Delivery At-least-once Exactly-once A: consumers must be idempotent. B: much more plumbing, often illusory
Topic layout One topic per table One topic for all A: ordering per table only. B: hard to scale consumers
Snapshot Blocking snapshot Incremental snapshot A: lock or long read up front. B: more complex, newer
Coupling Ship raw table rows Ship domain events A: consumers know your schema. B: more app work

Raw rows versus domain events is the one I argue about most in design reviews.

  • Log-based CDC naturally emits raw table changes: "column status on row 42 went from 1 to 2."
  • Downstream consumers now have to know that status 2 means "shipped."
  • You have leaked your internal schema across service boundaries.
  • The day someone renames a column in a migration, every consumer breaks.
  • The cleaner answer for cross-team contracts is the transactional outbox: the app writes a well-formed domain event ("OrderShipped") into an outbox table in the same transaction as the business change, and CDC captures that table.
  • You get the atomicity of CDC with the stable contract of events.
  • My rule: outbox whenever the consumer is another team, raw CDC when it is my own search index or warehouse and I control both ends.

The second trade-off worth internalizing: CDC buys you eventual consistency, not consistency.

  • There is always lag between the commit on the primary and the change landing downstream.
  • Usually under a second. Under load, or during a snapshot, it can be minutes.
  • If a user updates their profile and immediately searches for it, your index may not have caught up.
  • Design for that or you will get bug reports that are not bugs.
From the interviewer's chair

When I ask about trade-offs, I am listening for whether you name the lag out loud before I do. Candidates who present CDC as a clean sync and never mention the read-your-writes gap have not run it in anger. Tell me where the seconds go, and tell me which consumers can tolerate them.

5Where it is used#

CDC shows up anywhere two data systems need to agree without a two-phase commit between them.

  • Search indexing. The classic. Orders live in Postgres, full-text search lives in Elasticsearch. You do not want the order service talking to Elasticsearch on every write, because a search cluster hiccup would then fail an order. CDC streams order changes into the indexer, and the index lags by a second or two. Fine for search.
  • Cache invalidation. Instead of the app remembering every cache key to bust on a write, a CDC consumer watches the table and evicts or updates the relevant entries. The database change is the invalidation signal, so you can never forget to bust a key.
  • Data warehouse and analytics. Nightly batch ETL is dying. Modern setups stream row changes into Snowflake or BigQuery continuously so analysts see near-real-time data. CDC is the pipe, and this is the most common reason "Big Data" teams adopt it.
  • Microservice data sharing. Service A owns the customer table. Service B needs a read-only copy to avoid synchronous calls on its hot path. CDC (ideally via an outbox) feeds B a local materialized view it can query at memory speed.
  • Audit and replication. The log already contains before-and-after images with transaction IDs, so CDC gives you an audit trail almost for free, and it powers cross-region replication where native database replication will not reach.

If the same data is needed in more than two places, I start sketching a CDC pipeline.

6Common pitfalls and gotchas#

Pause before you read on: your connector writes a change to Kafka, then crashes before committing its log offset. What does the consumer see after restart?
Reveal the answer
It sees the same change twice. On restart the connector replays from the last committed offset, which is before the message it already delivered. That is why CDC is at-least-once, and why every consumer must be idempotent. If you cannot explain this crash window, you cannot defend the design.

"Exactly-once" is mostly a lie.

  • Delivery is at-least-once: the connector can crash after writing to Kafka but before committing its offset, and on restart it replays.
  • So every consumer must be idempotent: upsert by primary key, dedup on an event ID, or key Kafka messages so the same row always lands in the same partition.
  • A candidate who claims exactly-once with no idempotency on the consumer is telling me the pipeline will double-process rows in production.

Ordering is per partition, not global.

  • Kafka only guarantees order within a partition.
  • Key by table name and you serialize the whole table through one partition, losing parallelism.
  • Key by primary key and you keep order per row (usually what you actually need) while scaling across partitions.
  • But you have now lost cross-row ordering, so a consumer cannot assume it sees order 41 before order 42.
  • Know which guarantee your downstream needs before you pick the key.

Schema changes break the pipe.

  • Someone adds a column, drops one, or changes a type.
  • Log-based connectors have to decode the new log format, and downstream consumers have to handle the new shape.
  • Without a schema registry and compatibility rules, a Monday-morning migration becomes a Monday-morning outage.
  • Treat the CDC event schema as a public API with versioning.

Snapshots can take out your primary.

  • The initial snapshot of a huge table is a long-running read.
  • Older tools took locks or ran one giant transaction that blocked vacuum on Postgres and bloated the database.
  • Use incremental snapshotting (chunked reads interleaved with the live stream) on large tables, and run the snapshot off a replica if you can.

Deletes and tombstones.

  • A delete in CDC often shows up as a change with a null after-image, followed by a tombstone message.
  • Consumers that only handle inserts and updates will quietly keep deleted rows forever in the search index.
  • I have seen a "ghost results" search bug traced straight to a consumer ignoring tombstones.
Red flag that fails you here

Saying the WAL is free and moving on. The database cannot throw away log segments that CDC has not consumed yet. If your connector is down for a day, Postgres keeps every WAL segment waiting, the primary runs out of disk, and the whole database falls over. Monitor replication slot lag like it is a production SLO, because it is. Candidates who never mention this have not operated CDC through an outage.

7How it shows up in interviews#

Interviewers reach for CDC in any "keep two datastores in sync" question: design a search system, a news feed, an e-commerce catalog, analytics on top of an OLTP database.

  • The moment I ask "what happens if the search write fails after the database write succeeds," a weak candidate is stuck.
  • That dual-write problem is exactly the door CDC walks through.

Here is the contrast I watch for on the whiteboard.

Weak answer: "The order service writes the order to Postgres and then writes the same order to Elasticsearch in the same request handler. If I want it faster I can put the Elasticsearch write on a background thread."

  • It fails the first follow-up: if the Postgres write succeeds and the Elasticsearch write fails, the two stores disagree and nothing reconciles them.
  • A background thread makes it worse, because now the failure is silent.
  • There is no replay, no ordering guarantee, and order-service availability is now chained to the search cluster's availability.
  • This is the dual-write anti-pattern, and the candidate usually cannot say how they would rebuild a corrupted index without re-reading all 50 million rows from the primary.

Strong answer: "I would not dual-write. The service writes only to Postgres. Then log-based CDC, Debezium reading the WAL, streams row changes into a Kafka topic keyed by order_id across 12 partitions. The search indexer is a consumer group that bulk-writes to Elasticsearch. This decouples the write path from the search cluster, so a search outage never fails an order, and it gives me a replayable log. With 7-day Kafka retention, if the indexer falls behind or the index gets corrupted I reset the consumer offset and rebuild, no load on the primary."

  • Then, unprompted, they name the costs.
  • "This is eventually consistent, so there is 300 to 500 ms of lag in steady state and up to tens of seconds during a snapshot."
  • "Consumers must be idempotent because delivery is at-least-once, so I upsert by order_id."
  • "Deletes come through as tombstones, which the indexer has to handle or deleted orders linger in search."

The gap between those two answers is the whole interview.

  • The weak one treats CDC as a magic sync button.
  • The strong one treats it as an ordered, replayable, at-least-once event log with real lag and real schema coupling.
  • And it names which of those costs apply to the specific system on the whiteboard.
Follow-ups the interviewer will ask (and your answers)
"How do you handle the initial load of existing data?"
Snapshot first, then stream from the log position captured at snapshot time. For big tables I use incremental snapshotting so the primary never locks, and I run it off a read replica. Concretely, a 50 million row table backfills in about 42 minutes at 20,000 rows/second.
"What if a consumer processes the same change twice?"
That is expected, because delivery is at-least-once: the connector can replay after a crash. So the consumer is idempotent, upserting by primary key or deduping on an event ID. I never claim exactly-once with no idempotency, because that is the thing that double-processes rows in production.
"How do you avoid leaking your database schema to other teams?"
Transactional outbox. The app writes a well-formed domain event like OrderShipped into an outbox table in the same transaction as the business change, and CDC captures that table instead of the raw rows. Consumers bind to a stable event contract, not my column names, so a migration does not break them.
"What breaks when someone runs a schema migration?"
The connector has to decode the new log format and every consumer has to handle the new shape. I treat the CDC event schema as a public API: a schema registry with backward-compatible rules and versioned events, so a Monday migration does not become a Monday outage.
"How do you rebuild downstream if it gets corrupted?"
Replay from the log. With 7-day Kafka retention I reset the consumer group offset and rebuild from the stream with zero load on the primary. If I need older than the retention window, I re-run the snapshot. This replayability is CDC's superpower, so I lead with it rather than waiting to be asked.

What you can now explain in the room

  • Why one-writer-plus-a-log beats dual-writing to five systems, including the availability math.
  • The difference between query-based and log-based CDC, and why log-based wins for anything serious.
  • Why CDC is at-least-once, and exactly what makes a consumer idempotent.
  • How the snapshot-then-stream handoff works and why it is the hard part of every tool.
  • When to ship raw rows versus domain events, and why the outbox belongs at team boundaries.
  • The operational gotchas that cause real outages: WAL disk fill, tombstones, per-partition ordering, and lag.

Next, see the System Design Question walkthrough on designing a real-time search indexing pipeline, where CDC is the backbone of the whole answer.

Get a prep plan built around your Change Data Capture (CDC) interview

Tell us the role and your interview date. We will send back a dated, day by day plan. Free.

Thanks. Check your inbox, your Change Data Capture (CDC) prep plan is on the way.