system-design

Change Data Capture

Turning your database’s replication log into an event stream. It solves the dual-write problem completely, and it’s how derived systems stay in sync without anyone remembering to update them.

Prerequisites: Message Queues, Storage Engines, Replication Time to read: ~22 minutes


The problem

An order is created. Five systems need to know:

Postgres (source of truth) — the order row
Elasticsearch              — so it's searchable
Redis                      — cache invalidation
The data warehouse         — analytics
The notification service   — email the customer

Attempt 1 — dual writes:

db.insert(order)
search.index(order)
cache.delete(f"orders:{order.user_id}")
warehouse.insert(order)
notifications.send(order)

🚨 This is broken, and it’s broken in a way that fails silently. If search.index throws, the order exists in Postgres and is invisible in search — forever, with no error anyone will notice. If the process dies after the database insert, four systems never hear about it. There is no transaction spanning these systems, so partial failure is guaranteed at scale.

Attempt 2 — publish an event:

db.insert(order)
kafka.publish("order.created", order)     # ← what if this fails?

Same problem, one system smaller. The database write and the publish aren’t atomic. → The dual-write problem

Attempt 3 — poll the database:

SELECT * FROM orders WHERE updated_at > :last_check;

Better, but: it loads production on every poll, it misses deletes entirely, it misses rows created and deleted between polls, it requires an updated_at column that everything updates correctly, and clock skew or transactions committing out of order cause missed rows.


The insight

Your database already maintains a perfect, ordered, durable log of every committed change. It has to — that’s how crash recovery and replication work.

Postgres: the WAL (write-ahead log)
MySQL:    the binlog
MongoDB:  the oplog

Change Data Capture reads that log and publishes the changes as events.

flowchart LR
    A[Application] -->|writes| DB[(PostgreSQL)]
    DB -->|WAL| C[Debezium connector]
    C --> K[[Kafka]]
    K --> ES[Elasticsearch]
    K --> CA[Cache invalidator]
    K --> DW[Data warehouse]
    K --> N[Notification service]

🚨 Why this is qualitatively better than every alternative:

🎙️ This is one of the most valuable things to propose unprompted in a design interview, because most candidates reach for dual writes and don’t notice the problem.


How it works

Debezium is the standard implementation. It registers as a logical replication client — the database thinks it’s just another replica.

{
  "op": "u",
  "ts_ms": 1735689600000,
  "source": {"db": "shop", "table": "orders", "lsn": 24023119, "txId": 5821},
  "before": {"id": 42, "status": "pending", "total_cents": 4200},
  "after":  {"id": 42, "status": "paid",    "total_cents": 4200}
}

Every event carries the operation (create, update, delete, read-for-snapshot), the row before and after, and source metadata including the log position and transaction ID.

🚨 The before image is genuinely useful and easy to overlook: it lets consumers compute deltas (“the status changed from pending to paid” rather than just “it’s paid now”), which is what makes event-driven reactions precise.

Setup requirements:

The initial snapshot. When CDC starts, the log only has recent changes. So the connector first takes a consistent snapshot of existing rows (emitted as r events), then switches to streaming from the exact log position where the snapshot was taken. Debezium handles this transition, and doing it without downtime for very large tables is the operationally tricky part (incremental snapshotting exists for this).


What CDC is used for

Use case Why CDC fits
Keeping a search index current Never miss an update; no application changes → Search
Cache invalidation Precisely invalidate when the underlying row actually changes
Feeding the data warehouse Near-real-time, no heavy periodic exports → Analytics
Microservice data sync Service B needs a read-only copy of Service A’s data
Audit logs An immutable record of every change, including who and when
Zero-downtime migrations Dual-write to the new schema without touching application code → Migrations
Materialized views Maintain denormalized read models incrementally
Strangler fig migrations Keep the legacy and new systems in sync during a gradual cutover

🚨 The microservice sync case deserves emphasis because it’s an increasingly standard pattern. Rather than Service B calling Service A’s API on every request (coupling, latency, availability multiplication), Service B subscribes to A’s change stream and maintains its own local read-only copy. Reads become local and fast, and A being down doesn’t take B down.

⚖️ The cost: B’s copy is eventually consistent, and you’ve created a dependency on A’s schema rather than its API — which is a real coupling concern (see below).


The outbox pattern: when you want events, not rows

🚨 The most important nuance in this chapter.

Raw CDC publishes table rows. That means consumers are coupled to your internal schema — rename a column and you break every downstream system. And a row change often doesn’t map cleanly to a business event: an order going from pending to paid is meaningful; the same row’s updated_at changing is not.

The transactional outbox pattern fixes this:

BEGIN;
  INSERT INTO orders (id, user_id, total_cents, status)
       VALUES (42, 7, 4200, 'paid');

  INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
       VALUES ('order', '42', 'OrderPaid',
               '{"order_id":42,"user_id":7,"amount":4200,"currency":"PKR"}');
COMMIT;                     -- atomic: both rows or neither

Then CDC streams the outbox table, not the business tables.

You publish deliberate business events with a stable, versioned contract — not incidental row changes. ✅ Still atomic — the event is in the same transaction as the data. ✅ Consumers aren’t coupled to your internal schema. You can refactor orders freely. ✅ You choose exactly which changes are events.

❌ Application code must write to the outbox (unlike raw CDC). ❌ The outbox table needs periodic cleanup (delete rows after they’re captured, or use a short-retention partitioned table).

🎙️ The distinction to draw in an interview: “Raw CDC on the business tables couples every consumer to our internal schema and turns every column change into an event. I’d use the transactional outbox — write a deliberate business event in the same transaction, and stream that. We get atomicity without exposing our schema as a public contract.”


The hard parts

Schema changes. 🚨 What happens downstream when you ALTER TABLE? Adding a nullable column is usually fine; renaming or dropping one breaks consumers. Mitigations: a schema registry with compatibility enforcement, the outbox pattern (so consumers never see your table schema), and treating any CDC-exposed table as a public API with a change process.

Deletes. CDC emits a delete event with the before image and (in Debezium) a tombstone — a message with a null value — which enables Kafka log compaction to actually remove the key. Soft deletes appear as ordinary updates, which consumers must interpret correctly.

Ordering. Preserved per-table and per-key if you partition Kafka by primary key. Order across tables is not guaranteed unless you’re careful — so a consumer might see an order_items row before the orders row it references. Handle this by tolerating out-of-order arrival, or by using the outbox so one event carries the whole aggregate.

Transaction boundaries. By default, CDC emits per-row events, so a transaction that updated 3 tables becomes 3+ independent events and consumers see intermediate states. Debezium can emit transaction metadata markers to let consumers reassemble them; the outbox pattern sidesteps it entirely.

Backfill. The initial snapshot of a billion-row table is heavy and slow. Incremental snapshotting (chunked, interleaved with streaming) exists for this and is worth knowing about.

Replication slot management. As above — an unconsumed slot fills your disk and stops the database. Alert on it.

Sensitive data. 🚨 CDC streams everything in the table, including password hashes, PII, and payment details. Column filtering and masking must be configured deliberately, and this is a common compliance oversight. → Privacy & Compliance


⚖️ Trade-offs

Decision Gain Cost
CDC over dual writes No lost events; atomic by construction A pipeline to operate; eventual consistency
CDC over polling Captures deletes; no production query load; nothing missed Requires log access and configuration
Raw table CDC Zero application changes Consumers coupled to internal schema; noisy events
Outbox pattern Deliberate, versioned business events Application must write the outbox; cleanup needed
Log compaction on CDC topics Bounded storage; replayable current state Loses intermediate history

In the real world


🚨 Interview traps


🎙️ Soundbites


🛠️ Try it

1. Set up Debezium end to end. Docker Compose with Postgres, Kafka, and Debezium — about 20 minutes with the official images:

ALTER SYSTEM SET wal_level = logical;   -- then restart
CREATE TABLE orders (id SERIAL PRIMARY KEY, status TEXT, total INT);

Register the connector, then INSERT, UPDATE, and DELETE a row while watching the Kafka topic:

kafka-console-consumer --topic dbserver1.public.orders --from-beginning \
  --bootstrap-server localhost:9092

Watching an UPDATE produce an event with both before and after is the moment this clicks.

2. Implement the outbox pattern. Add an outbox table, write to it in the same transaction as a business change, and configure Debezium’s outbox event router. Compare the events you get to raw table CDC — the difference in cleanliness is the argument.

3. Break it deliberately. Stop your CDC consumer and keep writing to the database. Then check:

SELECT slot_name, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots;

Watch the retained WAL grow. That number is what fills your disk if you don’t alert on it.

4. Prove polling misses deletes. Write a poller using updated_at, then delete a row. The poller never learns about it. CDC does.


Check yourself

1. What is the dual-write problem and how does CDC solve it? Writing to a database and publishing to a message broker are two separate systems with no shared transaction, so either can fail after the other succeeded — leaving your database and downstream consumers permanently inconsistent, silently. CDC eliminates the problem structurally: the application writes only to the database, and the change is captured from the database's own write-ahead log *after* the transaction commits. If the transaction committed, the change is in the log by definition; if it didn't, there's nothing to publish. There is no window where one succeeded and the other didn't.
2. Why is CDC better than polling WHERE updated_at > last_check? Polling misses deletes entirely (the row is gone, so no query finds it). It misses rows created and deleted between polls. It depends on every writer correctly maintaining `updated_at`, which a migration script or manual SQL fix won't do. It can miss rows due to transactions committing out of order relative to their timestamps. And it runs a query against production on every poll, getting more expensive as the table grows. CDC reads the replication log, which contains every committed change in commit order, with essentially no query load on the database.
3. What is the transactional outbox pattern and why prefer it to raw table CDC? You insert a deliberate business event into an `outbox` table inside the same transaction as the business data, then run CDC over the outbox table rather than the business tables. Advantages: you publish **meaningful business events** (`OrderPaid`) rather than incidental row changes (every `updated_at` touch); the event payload is a **versioned contract** you control, so consumers aren't coupled to your internal schema and you can freely refactor tables; and one event can carry a whole aggregate rather than fragmenting a transaction across several table changes. You still get atomicity, because the outbox insert is in the same transaction. Costs: application code must write the outbox, and the table needs cleanup.
4. What happens if your CDC consumer stops for a week? The replication slot holds its position, so no data is lost — when the consumer restarts it resumes from where it stopped. But the database **cannot recycle WAL segments beyond that position**, so WAL accumulates on disk for the entire week. If nobody is monitoring it, the disk fills and Postgres stops accepting writes — a full production outage caused by a stopped consumer. This is a genuine, recurring incident pattern. Mitigations: alert on replication slot lag and retained WAL size, set `max_slot_wal_keep_size` so the slot is invalidated rather than filling the disk (accepting that you'd then need a re-snapshot), and treat CDC consumer health as a production-critical alert.
5. Your CDC pipeline streams a users table to the analytics warehouse. What compliance concern should you raise? CDC streams **every column by default** — including password hashes, email addresses, phone numbers, national ID numbers, and anything else in the table. That data now exists in Kafka (with its retention period), in the warehouse, and in every downstream consumer, often with weaker access controls than the source database. This creates GDPR/privacy exposure and makes "delete all data for this user" much harder, since the data has been copied to systems that may not support targeted deletion. Mitigations: configure column filtering and masking in the connector, encrypt or tokenize sensitive fields before they enter the stream, set short retention on topics carrying PII, and maintain a data inventory so erasure requests can actually be fulfilled.

Further reading