Table of Contents

    CDC

    SYSTEM DESIGN • CHAPTER 15.9

    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.

    Learning objective: By the end of this article, you will understand log-based versus query-based capture, snapshot and streaming phases, schema evolution, the internal-model coupling problem, deletes and tombstones, and when CDC is the right choice against an outbox.

    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.

    THE INSIGHT
    Write-Ahead Log Already Ordered and Committed Publish It
    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.

    CDC does not add a mechanism for reliability. It borrows one the database already depends on for its own correctness.

    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
    Query-Based Loses Data Silently A row updated twice between polls yields one event. A row deleted yields none. Downstream systems diverge without any error being raised.
    Log-Based Is Complete by Construction Because the log is what the database uses for recovery, a change absent from the log would be a change the database itself could lose. Completeness is guaranteed by the storage engine.

    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
    The before image is valuable: It lets consumers compute exactly what changed, apply conditional logic on prior state, and reverse a change during reconciliation without a separate lookup.

    Snapshot and Streaming

    A new consumer needs existing data, not only future changes. CDC connectors therefore operate in two phases.

    TWO PHASES
    Snapshot Existing Rows Record Log Position Stream From There
    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 Snapshot Boundary Hazard If the recorded log position does not exactly match the snapshot point, changes are either duplicated or lost at the handover. The connector must obtain both atomically.
    Incremental Snapshots Solve Restarts A locking or single-transaction snapshot of a very large table may run for hours and must restart from scratch on failure. Chunked snapshots resume from the last completed chunk.

    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
    You Can No Longer Refactor Freely Once teams consume your table structure, a schema migration becomes a breaking change to systems you may not know exist. The database stops being private.
    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
    THE DECIDING DISTINCTION
    Use raw CDC for replication targets you control. Use CDC on an outbox table when other teams consume the stream, so the contract is something you designed rather than something you leaked.

    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
    Deletes carry the deleted data: The before image of a delete contains the full row, including personal information. Under deletion obligations, that event itself becomes data requiring removal.
    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
    Bulk Operations Bypass the Row Log Truncating a table or dropping a partition may produce no row-level events. Downstream systems retain data the source has discarded, with no signal that anything happened.

    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
    Transactions Are Split Apart One transaction updating an order and its line items produces separate events, likely on separate topics. A consumer may see the order change before the items, observing a state that never existed atomically in the source.
    {
        "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 }
        ]
    }
    Buffer by Transaction Where It Matters Connectors can emit transaction markers, letting a consumer accumulate all events for one transaction and apply them together rather than observing partial states.

    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
    A stopped connector can fill your disk: The database retains log segments the replication slot has not consumed. A connector down over a weekend can exhaust storage and take the source database offline.
    -- 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

    MIGRATION SEQUENCE
    Snapshot to Target Stream Ongoing Changes Verify Parity Cut Over
    Why CDC Suits Migration Particularly Well The target stays continuously current, so cutover is a routing change rather than a maintenance window. Reverse replication can also be configured to permit rollback.

    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
    CDC cannot express intent: A status column changing from confirmed to cancelled does not say whether the customer cancelled, payment failed, or fraud was detected. Those are different events with different downstream consequences, and only an outbox can distinguish them.

    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
    Bulk Updates Are an Operational Hazard A maintenance script touching ten million rows produces ten million events. Downstream consumers, search indexes, and warehouses may take hours to recover from an operation that took seconds.

    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
    Replication slot lag is the metric that can take down your source database. Treat it with higher urgency than consumer lag, because the failure it causes is not recoverable by simply catching up.

    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

    1

    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.

    2

    What does query-based capture miss?

    Deletes, intermediate states between polls, and before images, all without raising any error.

    3

    What is the coupling problem?

    Publishing row changes makes the internal schema a public contract, so ordinary refactoring becomes a breaking change for consumers.

    4

    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.

    5

    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.