Change Data Capture (CDC) Patterns

Implement CDC for real-time data synchronization. Covers Debezium, log-based CDC, query-based CDC, outbox pattern, event sourcing, and CDC pipeline architecture.

Change Data Capture (CDC) is a set of techniques for identifying and propagating changes made to a data store to downstream systems. This approach matters when you need to maintain consistency between systems, support real-time analytics, or build event-driven architectures where data movement must be both timely and complete.

Capture Methods

Log-Based Capture

Log-based CDC reads transaction logs from the source database to detect changes. Most relational databases support this through built-in log mechanisms.

For PostgreSQL, enable logical replication with:

CREATE PUBLICATION mypub FOR TABLE users, orders;

Then connect using pg_recvlogical:

pg_recvlogical -d mydb -S myslot -R -f -t 10

The -S flag specifies the replication slot name. If a replication slot is not available, the command fails with:

ERROR:  replication slot "myslot" does not exist

The pg_recvlogical tool does not handle slot creation automatically; you must create it first using pg_create_logical_replication_slot.

Polling-Based Capture

Polling CDC reads changes by querying a timestamp or version column. This method works with any system that supports SQL.

Example query for detecting new or updated rows:

SELECT * FROM users WHERE updated_at > '2023-01-01T00:00:00Z';

For efficient polling, use a cursor with a limit:

SELECT id, name, updated_at 
FROM users 
WHERE updated_at > $1 
ORDER BY updated_at ASC 
LIMIT 1000;

When using a timestamp column, ensure it has sufficient precision. A DATE type will silently truncate to day-level granularity, leading to missed changes.

Trigger-Based Capture

Trigger-based CDC uses database triggers to capture changes into a dedicated audit table. This method works with any SQL system that supports triggers.

Example trigger function:

CREATE OR REPLACE FUNCTION audit_trigger()
RETURNS TRIGGER AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        INSERT INTO audit_log (table_name, operation, row_data)
        VALUES (TG_TABLE_NAME, 'INSERT', row_to_json(NEW));
        RETURN NEW;
    END IF;
END;
$$ LANGUAGE plpgsql;

Create the trigger:

CREATE TRIGGER users_audit_trigger
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION audit_trigger();

If a trigger function raises an exception, the original operation fails and must be retried manually. The error message will show the trigger's failure, but not the specific row that caused it.

Change Processing

Row-Level Change Stream

A change stream processes each modified row individually. This is useful for systems requiring fine-grained change handling.

Example change processing function:

def process_change(change):
    operation = change['operation']
    table = change['table']
    data = change['data']
    
    if operation == 'INSERT':
        # Handle insert logic
        pass
    elif operation == 'UPDATE':
        # Handle update logic
        pass
    elif operation == 'DELETE':
        # Handle delete logic
        pass

When processing changes, ensure that operations are idempotent. A repeated change event should not alter the target system's state.

Batched Change Processing

Batched processing collects multiple changes and applies them together, reducing overhead.

Example batch processing:

def process_batch(changes):
    # Group by table for efficient batch operations
    grouped = {}
    for change in changes:
        table = change['table']
        if table not in grouped:
            grouped[table] = []
        grouped[table].append(change)
    
    for table, table_changes in grouped.items():
        # Process each table's changes as a batch
        pass

When batching, be careful about ordering. Changes within a batch must maintain their original sequence to ensure correct application.

Change Resolution

Resolution handles conflicts between different change sources or versions.

Example conflict resolution logic:

def resolve_conflict(changes):
    # Sort by timestamp and apply in order
    sorted_changes = sorted(changes, key=lambda x: x['timestamp'])
    
    # Apply each change in sequence
    for change in sorted_changes:
        apply_change(change)

If two changes modify the same row with different timestamps, the later one overwrites the earlier one. This can silently drop changes if not properly tracked.

Data Format and Serialization

JSON Change Format

Changes are typically serialized as JSON objects containing metadata and data.

Example change object:

{
  "operation": "UPDATE",
  "table": "users",
  "timestamp": "2023-01-01T12:00:00Z",
  "data": {
    "id": 1,
    "name": "John Doe",
    "email": "[email protected]"
  },
  "old_data": {
    "name": "Jane Doe"
  }
}

The old_data field contains the previous values for update operations. If an operation does not support this, the field will be missing, and downstream systems must handle the absence.

Binary Change Format

Some systems serialize changes in binary formats for performance.

Example using a custom binary format:

import struct

def encode_change(operation, table, data):
    # Pack operation type, table name length, table name, data length, data
    return struct.pack(
        f'!B{len(table)}sI{len(data)}s',
        operation,
        table.encode(),
        len(data),
        data.encode()
    )

Binary serialization requires both sender and receiver to agree on the format. Mismatched formats will cause decode errors.

Change Delivery

Direct Push Delivery

Changes are pushed directly to a destination system using HTTP or message queues.

Example HTTP push:

import requests

def push_change(change):
    response = requests.post(
        'http://destination.example.com/changes',
        json=change,
        headers={'Content-Type': 'application/json'}
    )
    
    if response.status_code != 200:
        raise Exception(f"Delivery failed: {response.text}")

If the destination system is unavailable, delivery will fail with a 5xx error or timeout. The change will need to be retried.

Queued Delivery

Changes are stored in a queue before delivery, providing reliability.

Example using a message queue:

import json

def queue_change(change):
    # Send to queue system
    queue.put(json.dumps(change))

Queues provide at-least-once delivery guarantees. If a change is processed but the acknowledgment fails, it may be delivered again.

Failure Handling

Change Loss Recovery

When changes are lost, recovery must identify and replay missing events.

Example recovery process:

def recover_missing_changes(last_processed_id):
    # Query for changes that occurred after last processed event
    changes = fetch_changes_after(last_processed_id)
    
    # Apply changes in order
    for change in changes:
        apply_change(change)

If a system lacks a reliable sequence number, recovery becomes difficult and may miss changes.

Duplicate Handling

Duplicate changes must be handled to prevent data corruption.

Example duplicate detection:

def is_duplicate(change):
    # Check if this change was already processed
    return db.exists('SELECT 1 FROM processed_changes WHERE id = ?', (change['id'],))

If a system does not track which changes have been applied, it will process the same change multiple times.

Retry Logic

Retry logic handles temporary failures in delivery or processing.

Example retry mechanism:

import time

def retry_with_backoff(func, max_retries=3):
    for attempt in range(max_retries):
        try:
            return func()
        except Exception as e:
            if attempt == max_retries - 1:
                raise e
            time.sleep(2 ** attempt)

Retries may cause duplicate processing. Ensure that retry logic does not create race conditions.

Monitoring and Debugging

Change Tracking

Track which changes have been processed:

CREATE TABLE change_log (
    id BIGSERIAL PRIMARY KEY,
    change_id UUID NOT NULL,
    processed_at TIMESTAMP DEFAULT NOW(),
    status VARCHAR(20) NOT NULL
);

If a change is not marked as processed, the system will retry it.

Error Reporting

Report errors during change processing:

def process_change_with_error_handling(change):
    try:
        process_change(change)
        mark_as_processed(change['id'])
    except Exception as e:
        report_error(change, str(e))
        raise

Errors must be logged with sufficient context for debugging.

Change Validation

Validate that changes conform to expected formats:

def validate_change(change):
    required_fields = ['operation', 'table', 'data']
    for field in required_fields:
        if field not in change:
            raise ValueError(f"Missing required field: {field}")

If validation fails, the system should reject the change rather than attempt processing.

Common Pitfalls

Clock Skew

Timestamps from different systems may have different time zones or clocks.

Example of problematic timestamp handling:

# This will fail if clocks are not synchronized
def process_changes(changes):
    for change in changes:
        # Using local time instead of UTC
        if change['timestamp'] > datetime.now():
            # Future-dated change
            pass

Always use UTC timestamps and validate that systems have synchronized clocks.

Schema Changes

Schema evolution can break change processing.

Example schema change:

ALTER TABLE users ADD COLUMN phone VARCHAR(20);

If a change processing system does not handle new columns, it may silently ignore them or fail on unexpected fields.

Change Order Violations

Processing changes in wrong order can cause data inconsistency.

Example ordering problem:

# Wrong: Processing without maintaining order
def process_changes_unordered(changes):
    for change in changes:
        apply_change(change)

Maintain proper ordering by processing changes in timestamp order or by using sequence numbers.

Resource Exhaustion

Processing large volumes of changes can exhaust system resources.

Example resource management:

def process_changes_with_limits(changes, batch_size=1000):
    for i in range(0, len(changes), batch_size):
        batch = changes[i:i + batch_size]
        process_batch(batch)

Always limit batch sizes to prevent memory overflow or timeout issues.

The key to successful CDC implementation is understanding the specific requirements of your data flow and handling failures gracefully. Each pattern must be carefully tested for edge cases and integrated with appropriate monitoring to detect and resolve issues quickly.

This page was rewritten on 15 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.