CDC
Change Data Capture
Understand how reading a database's own replication log turns every committed change into a reliable event stream, why it avoids the dual-write problem entirely, and the coupling it creates in exchange.
Prerequisites
Recommended Knowledge
- The transactional outbox and dual-write problem
- Write-ahead logging and database recovery
- Replication and replica lag
- Message brokers and delivery semantics
- Idempotency and duplicate handling
- Ordering guarantees and partition keys
- Schema evolution and compatibility
- Batch and streaming pipelines
What CDC Reads
Every durable database already maintains an ordered, committed record of every change it has made. It exists for crash recovery and replication. Change data capture consumes that same log as an event source.
| Property of the Log | Why It Matters for CDC |
|---|---|
| Contains only committed changes | No uncommitted state is ever published |
| Strictly ordered | Transaction order is preserved naturally |
| Durable before acknowledgement | Nothing acknowledged can be missed |
| Complete | Every change appears, without exception |
| Position-addressable | A consumer can resume from where it stopped |
Simple Analogy
A shop already keeps a complete daily ledger for its own accounting. Rather than asking staff to separately report each sale, you simply read the ledger. Nothing can be forgotten because the business depends on it being complete.
Log-Based Versus Query-Based
Before log-based capture became widely available, systems polled tables for changed rows. The approaches differ substantially in completeness.
| Aspect | Query-Based | Log-Based |
|---|---|---|
| Mechanism | Poll for rows changed since a timestamp | Read the write-ahead log |
| Captures deletes | No, the row is simply gone | Yes |
| Captures intermediate states | No, only the latest value | Yes, every change |
| Before and after images | After only | Both |
| Database load | Repeated table scans | Minimal, sequential log read |
| Schema requirement | Timestamp or version column | None |
| Latency | Bounded by poll interval | Near-immediate |
| Setup complexity | Low | Higher, requires log access |
What a Change Event Contains
{
"source": {
"connector": "postgresql",
"db": "commerce",
"schema": "public",
"table": "orders",
"lsn": 28419374,
"txId": 4471902,
"commitTs": "2026-09-24T06:52:11.482Z"
},
"op": "u",
"before": {
"order_id": "order-7734",
"status": "confirmed",
"total": 249900,
"updated_at": "2026-09-24T06:40:00Z"
},
"after": {
"order_id": "order-7734",
"status": "shipped",
"total": 249900,
"updated_at": "2026-09-24T06:52:11Z"
}
}
| Operation | Before | After | Meaning |
|---|---|---|---|
| c | Absent | New row | Insert |
| u | Prior values | New values | Update |
| d | Deleted row | Absent | Delete |
| r | Absent | Current row | Snapshot read |
Snapshot and Streaming
A new consumer needs existing data, not only future changes. CDC connectors therefore operate in two phases.
| Snapshot Mode | Behaviour | Trade-off |
|---|---|---|
| Locking snapshot | Blocks writes during the read | Consistent, but causes an outage |
| Consistent read | Uses a transaction snapshot | No blocking, uses database resources |
| Incremental snapshot | Chunked, interleaved with streaming | No blocking, more complex |
| Skip snapshot | Stream only from now | Consumer lacks existing data |
The Coupling Problem
This is the central design tension in CDC. Publishing row changes exposes your internal schema as a public contract, whether or not you intended it.
| Internal Change | Consumer Impact |
|---|---|
| Rename a column | Field disappears from every consumer |
| Split a table | Stream structure changes entirely |
| Change a data type | Parsing may fail downstream |
| Normalize a denormalized field | Consumers must join data they did not have |
| Add an internal working column | Leaks implementation detail outward |
| Batch update for maintenance | Floods consumers with meaningless events |
| Mitigation | Mechanism | Cost |
|---|---|---|
| CDC on an outbox table | Publish deliberate domain events | Requires writing outbox rows |
| Transformation layer | Map rows to a stable public shape | A component to maintain |
| Published views | Capture from a stable view | Not all databases support it |
| Schema registry | Enforce compatibility rules | Blocks incompatible migrations |
| Internal-only consumers | Restrict to your own team | Limits the pattern's usefulness |
CDC Combined With the Outbox
The two patterns are complementary rather than competing. The outbox provides the contract; CDC provides the delivery.
BEGIN;
UPDATE orders
SET status = 'shipped',
updated_at = CURRENT_TIMESTAMP
WHERE order_id = :order_id;
INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES (
'order',
:order_id,
'OrderShipped',
:domain_event_payload
);
COMMIT;
-- A CDC connector on the outbox table publishes the event,
-- while the orders table remains entirely private
What This Combination Gains
- No polling relay to operate
- Near-immediate publication
- Deliberate event shapes
- Internal schema stays private
- Atomicity from the transaction
What It Costs
- Connector infrastructure to run
- An extra write per transaction
- Log retention must be managed
- Database log access required
Deletes and Tombstones
Deletes require special handling because a compacted topic must know the key is gone, not merely that its last value was a delete event.
{
"key": { "order_id": "order-7734" },
"value": {
"op": "d",
"before": { "order_id": "order-7734", "status": "cancelled" },
"after": null
}
}
{
"key": { "order_id": "order-7734" },
"value": null
}
| Message | Purpose | Effect on Compaction |
|---|---|---|
| Delete event | Informs consumers of the deletion | Retained as the latest value |
| Tombstone | Marks the key for removal | Key eventually purged from the topic |
| Delete Style | Appears in Log As | Consumer Handling |
|---|---|---|
| Hard delete | Delete operation | Remove the record downstream |
| Soft delete | Update setting a flag | Must interpret the flag |
| Cascade delete | Many delete operations | Potentially large event burst |
| Partition drop | Often no row events at all | Silently diverges |
Ordering and Transactions
The log preserves commit order, but that ordering survives only if the downstream topology respects it.
| Guarantee | Preserved When | Lost When |
|---|---|---|
| Per-row ordering | Partitioned by primary key | Random partition assignment |
| Per-table ordering | Single partition per table | Multiple partitions |
| Cross-table ordering | Single topic and partition | Separate topics per table |
| Transaction boundaries | Transaction metadata emitted | Row events consumed individually |
{
"status": "BEGIN",
"id": "4471902",
"ts_ms": 1790007131482
}
{
"status": "END",
"id": "4471902",
"event_count": 4,
"data_collections": [
{ "data_collection": "public.orders", "event_count": 1 },
{ "data_collection": "public.order_items", "event_count": 3 }
]
}
Operational Constraints
| Constraint | Consequence If Violated | Mitigation |
|---|---|---|
| Log retention window | Connector falls behind and loses position | Retain longer than worst-case downtime |
| Replication slot held | Log accumulates, disk fills | Alert on slot lag, remove dead slots |
| Connector lag | Stale downstream data | Monitor lag in bytes and time |
| Failover to a replica | Log position may not carry over | Failover-aware slot handling |
| Large transactions | Memory pressure buffering events | Bound transaction size or stream them |
| Schema migration | Connector stalls or misparses | Compatible migrations, tested first |
-- Monitor replication slot lag before it becomes an incident
SELECT slot_name,
active,
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
) AS retained_wal
FROM pg_replication_slots
ORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC;
Where CDC Is Used
| Use Case | What CDC Provides | Coupling Concern |
|---|---|---|
| Search index sync | Every document change captured | Low, you own both sides |
| Cache invalidation | Reliable invalidation on write | Low |
| Data warehouse loading | Incremental instead of full reload | Moderate, schema tracked |
| Read model projection | Materialized views kept current | Low within one team |
| Outbox delivery | Publishing without a poller | None, outbox is the contract |
| Database migration | Continuous sync during cutover | None, temporary |
| Cross-service events | Domain notifications | High, prefer an outbox |
| Audit trail | Complete record of mutations | Low |
Zero-Downtime Migration
CDC Versus Outbox
| Dimension | Raw CDC | Outbox |
|---|---|---|
| Application changes | None required | Must write outbox rows |
| Event shape | Mirrors the table | Designed deliberately |
| Schema coupling | Strong | None |
| Captures intent | No, only resulting state | Yes, the event names it |
| Retrofitting | Works on legacy systems | Requires code changes |
| Infrastructure | Connector required | Poller or connector |
| Delivery guarantee | At-least-once | At-least-once |
Failure Modes
| Failure | Consequence | Mitigation |
|---|---|---|
| Connector stops | Log accumulates, disk fills | Alert on slot lag urgently |
| Log position lost | Full re-snapshot required | Durable offset storage |
| Retention exceeded | Gap in the change stream | Retention above worst-case downtime |
| Incompatible schema change | Consumers fail to parse | Schema registry with compatibility checks |
| Bulk update | Millions of events flood consumers | Throttle, or bypass with a coordinated reload |
| Source failover | Position invalid on the new primary | Failover-aware slots |
| Duplicate events on restart | Reprocessing from last offset | Idempotent consumers |
| Truncate or partition drop | No events emitted | Coordinate such operations explicitly |
Monitoring
Signals Worth Tracking
- Replication slot lag in bytes
- Connector lag in time behind the source
- Events emitted per second by table
- Snapshot progress and duration
- Connector restart frequency
- Schema change events detected
- Log retention headroom
- Consumer lag on published topics
- Event volume anomalies indicating bulk operations
- Row counts compared between source and target
Verification
describe("change data capture", function () {
it("emits an event for every operation type", async function () {
await source.insert("orders", { order_id: "o-1", status: "placed" });
await source.update("orders", "o-1", { status: "shipped" });
await source.delete("orders", "o-1");
const events = await stream.drain("orders");
expect(events.map(e => e.op)).toEqual(["c", "u", "d"]);
});
it("includes before and after images on update", async function () {
await source.insert("orders", { order_id: "o-2", total: 100 });
await source.update("orders", "o-2", { total: 200 });
const update = (await stream.drain("orders")).find(e => e.op === "u");
expect(update.before.total).toBe(100);
expect(update.after.total).toBe(200);
});
it("resumes from the recorded position after restart", async function () {
await source.insert("orders", { order_id: "o-3" });
await connector.stop();
await source.insert("orders", { order_id: "o-4" });
await connector.start();
const events = await stream.drain("orders");
const ids = events.map(e => e.after?.order_id);
expect(ids).toContain("o-4");
});
it("preserves per-row ordering under concurrent writes", async function () {
await Promise.all([
source.update("orders", "o-5", { status: "confirmed" }),
source.update("orders", "o-5", { status: "shipped" })
]);
const sequence = (await stream.drain("orders"))
.filter(e => e.after?.order_id === "o-5")
.map(e => e.after.status);
expect(sequence).toEqual(["confirmed", "shipped"]);
});
});
Common Design Mistakes
Weak Design
- Exposing raw tables to other teams
- Using query-based capture for completeness
- No alerting on replication slot lag
- Log retention shorter than possible downtime
- Bulk updates without coordination
- Assuming transaction boundaries survive
- Consumers that are not idempotent
- Random partitioning losing row order
- Ignoring the delete and tombstone distinction
Strong Design
- CDC on an outbox for external consumers
- Log-based capture for completeness
- Urgent alerting on slot lag
- Generous retention headroom
- Bulk operations planned with consumers
- Transaction markers where atomicity matters
- Idempotent consumers throughout
- Partitioning by primary key
- Explicit tombstone handling
System Design Interview Discussion
| Question | What Your Answer Should Cover |
|---|---|
| What does CDC read? | The database's own write-ahead log |
| Why not poll for changes? | Misses deletes and intermediate states |
| How does a new consumer get history? | Snapshot then stream from a recorded position |
| What is the main drawback? | Schema becomes a public contract |
| CDC or outbox? | Coupling and whether intent matters |
| What if the connector stops? | Log accumulates and can fill disk |
| Are transactions preserved? | Only with transaction markers |
| How do bulk updates behave? | Event floods requiring coordination |
Design Checklist
Production Checklist
- Use log-based capture rather than polling
- Decide whether consumers see tables or an outbox
- Partition by primary key to preserve row order
- Emit transaction markers where atomicity matters
- Store connector offsets durably
- Set log retention above worst-case downtime
- Alert urgently on replication slot lag
- Make all consumers idempotent
- Handle deletes and tombstones explicitly
- Register schemas with compatibility enforcement
- Coordinate bulk updates with consumers
- Use incremental snapshots for large tables
- Verify row counts between source and target
- Plan connector behaviour across source failover
- Test restart, snapshot resume, and schema change
Knowledge Check
Why is log-based capture complete?
The log is what the database uses for recovery, so a change missing from it would be a change the database itself could lose.
What does query-based capture miss?
Deletes, intermediate states between polls, and before images, all without raising any error.
What is the coupling problem?
Publishing row changes makes the internal schema a public contract, so ordinary refactoring becomes a breaking change for consumers.
Why can a stopped connector be urgent?
The source database retains log segments the replication slot has not consumed, so prolonged downtime can exhaust disk and halt the database.
Why can CDC not express intent?
It reports that a value changed, not why. Distinct business events producing the same column change are indistinguishable in the stream.
Summary
Change data capture turns a database's write-ahead log into an event stream. Because that log already contains every committed change in order, and the database depends on its completeness for recovery, CDC inherits reliability rather than adding a new mechanism for it.
Log-based capture is materially stronger than polling, which misses deletes and intermediate states silently. A new consumer begins with a snapshot and then streams from the exact log position at which the snapshot was taken.
The central drawback is coupling. Publishing row changes exposes the internal schema as a contract, so migrations become breaking changes and the database stops being private. CDC also cannot express intent, since it reports what changed rather than why.
The strongest arrangement for cross-service events combines both patterns: write a deliberate domain event to an outbox table in the business transaction, and let a CDC connector publish it. That yields atomicity, low latency, no polling relay, and a contract you designed rather than one you leaked.
Key Takeaway
CDC gives you completeness for free and coupling as the price. Use raw capture for targets you own, such as search indexes, caches, and warehouses. Put CDC on an outbox when other teams consume the stream. Monitor replication slot lag as urgently as any production alert, because a stalled connector can take the source database down with it.