Production engineering guide for cdc change data capture covering patterns, implementation strategies, and operational best practices.
Change Data Capture (CDC) is a pattern for efficiently detecting and propagating data changes—inserts, updates, and deletes—from a source database to downstream systems. It matters when real-time or near-real-time data synchronization is required across services, analytics pipelines, caches, or event-driven architectures. CDC is the foundation for building event-sourced systems, data warehouses, and reactive microservices. It replaces inefficient polling, full-table scans, and manual ETL triggers with an automated, incremental, and scalable way to move data as it evolves.
Change Data Capture begins with identifying *what* changed and *when*. The source system must record modifications to its data over time. The most common approach is to use a log-based mechanism that captures changes as events in a structured format. The source database writes a log of changes to a persistent, append-only stream—typically a transaction log or WAL (Write-Ahead Log)—and downstream systems read this log to discover new or modified data.
The log must include:
INSERT, UPDATE, DELETEThe simplest form of CDC uses a change table in the source database: a table that records each change as a row. A trigger or a background process writes to this table after every DML operation. The change table can be queried by a downstream service to fetch all changes since a given timestamp or sequence number.
CREATE TABLE change_log (
id BIGSERIAL PRIMARY KEY,
table_name VARCHAR(64) NOT NULL,
record_id VARCHAR(64) NOT NULL,
operation_type VARCHAR(10) NOT NULL CHECK (operation_type IN ('INSERT', 'UPDATE', 'DELETE')),
old_values JSONB,
new_values JSONB,
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed BOOLEAN DEFAULT FALSE
);
A service reading this table uses the changed_at or id as a cursor to fetch changes incrementally. A typical read loop looks like:
SELECT id, table_name, record_id, operation_type, old_values, new_values, changed_at
FROM change_log
WHERE processed = FALSE
AND changed_at > '2024-04-05T10:00:00Z'
ORDER BY changed_at
LIMIT 1000;
When a batch is processed, the service marks its entries as processed = TRUE using a UPDATE or UPSERT to avoid reprocessing.
For higher throughput and lower latency, CDC systems stream changes directly from the database’s native transaction log. This approach eliminates the need for triggers and reduces the overhead of writing to a change table.
The key requirement is a log reader that can consume the log sequentially, starting from a known offset or LSN (Log Sequence Number). The reader maintains a cursor (a checkpoint) that tracks the last processed log entry.
import psycopg2
from psycopg2.extras import RealDictCursor
def read_changes_from_wal(connection, start_lsn: str):
cursor = connection.cursor(cursor_factory=RealDictCursor)
cursor.execute(f"""
SELECT
lsn,
xid,
op,
relname,
old_values,
new_values,
ts
FROM pg_wal_scan
WHERE lsn >= %s
ORDER BY lsn
""", [start_lsn])
while True:
batch = cursor.fetchmany(1000)
if not batch:
break
for record in batch:
yield {
"table": record["relname"],
"key": record["old_values"]["id"] if record["op"] == "DELETE" else record["new_values"]["id"],
"op": record["op"],
"old": record["old_values"],
"new": record["new_values"],
"timestamp": record["ts"],
"lsn": record["lsn"]
}
# Update checkpoint
last_lsn = batch[-1]["lsn"]
cursor.execute(
"INSERT INTO cdc_checkpoints (name, lsn) VALUES (%s, %s) "
"ON CONFLICT (name) DO UPDATE SET lsn = EXCLUDED.lsn",
["main", last_lsn]
)
connection.commit()
This reader reads from PostgreSQL’s pg_wal_scan view or uses pg_logical_slot_advance and pg_logical_slot_get_changes functions. The lsn serves as a durable, incrementing cursor that survives restarts and can be used to resume from any point.
Once changes are detected, they must be applied to a target system—another database, a message queue, a data warehouse, or a cache. The target system must support idempotent, incremental updates.
import requests
from concurrent.futures import ThreadPoolExecutor
def apply_change(change):
url = f"http://search-service:9200/index/_doc/{change['table']}/{change['key']}"
data = change["new"] if change["op"] == "INSERT" or change["op"] == "UPDATE" else None
method = "POST" if change["op"] == "INSERT" else "PUT"
headers = {"Content-Type": "application/json"}
try:
response = requests.request(
method=method,
url=url,
json=data,
headers=headers,
timeout=10
)
response.raise_for_status()
return {"success": True, "status": response.status_code}
except requests.RequestException as e:
return {"success": False, "error": str(e)}
The target system must handle:
new_values or old_values may change over time, requiring backward compatibility.To ensure idempotency, the target system can use a versioned key derived from the record's primary key and the change’s timestamp or LSN:
{
"table": "users",
"key": "12345",
"version": "2024-04-05T10:01:23.456Z",
"operation": "UPDATE",
"data": { "name": "Alice", "email": "[email protected]" }
}
When applying a change, the target system checks if a record with the same table, key, and version exists. If it does, the change is a no-op. If not, it creates or updates the record.
CDC systems fail silently. The most common issues are:
A CDC system must checkpoint the log reader’s progress. The checkpoint is a durable, persistent record of the last processed entry. On restart, the reader reads from the last checkpoint.
A typical checkpoint table:
CREATE TABLE cdc_checkpoints (
name VARCHAR(64) PRIMARY KEY,
lsn TEXT NOT NULL, -- e.g., '0/1A2B3C4D'
last_seen_at TIMESTAMPTZ NOT NULL,
processed_count BIGINT DEFAULT 0
);
On startup, the reader:
name (e.g., main, orders).lsn.If the reader crashes after processing 500 changes but before updating the checkpoint, the next run resumes from the same LSN, avoiding duplicates.
When the target system receives the same change twice—due to network failure, idempotency mismatch, or retry logic—the system must deduplicate.
The solution is deduplication via a deduplication ID. The CDC system generates a unique, stable identifier for each change. This ID is derived from:
CREATE TABLE change_dedup (
dedup_id VARCHAR(255) PRIMARY KEY,
change_id BIGINT,
processed_at TIMESTAMPTZ NOT NULL,
processed_by VARCHAR(64)
);
Before applying a change, the target system checks if dedup_id exists in change_dedup. If it does, the change is a duplicate and is skipped. Otherwise, the change is applied and dedup_id is recorded.
As the source database evolves—adding columns, changing types, or reorganizing tables—the structure of new_values and old_values may change. The CDC reader must handle schema drift.
The reader can emit a schema snapshot at regular intervals or on every schema change. This snapshot includes:
The target system uses this schema to deserialize new_values and old_values correctly. A change event with a schema version mismatch triggers a migration or warning.
CDC systems are tuned by adjusting the following parameters:
cdc:
source:
type: postgres
connection_string: "host=db.example.com user=cdc_user dbname=app"
log_reader:
start_lsn: "0/1A2B3C4D"
batch_size: 1000
checkpoint_interval: 300 # seconds
backoff:
initial_delay: 1000
max_delay: 30000
max_retries: 10
filters:
tables:
- users
- orders
operations:
- INSERT
- UPDATE
max_age: "1h"
target:
type: http
endpoint: "http://search-service:9200/index/_doc"
max_retries: 5
timeout: 10
deduplication:
enabled: true
dedup_id_template: "{{table}}/{{key}}/{{operation}}/{{lsn}}"
0/00000000 but the database has already been active for hours may miss changes.ORDER BY lsn is not enough; systems must validate or re-order changes.old_values: a common mistake is only applying new_values and ignoring old_values, leading to incomplete state tracking.A CDC pipeline should emit logs, metrics, and alerts. Key observability signals:
A health check endpoint returns:
{
"status": "healthy",
"source": {
"last_lsn": "0/1A2B3C4D",
"checkpoint_lsn": "0/1A2B3C4D",
"lag_seconds": 12.5
},
"target": {
"last_applied": "2024-04-05T10:01:30Z",
"last_checkpointed": "2024-04-05T10:00:00Z",
"retries": 34,
"duplicates": 12
},
"errors": [
{
"timestamp": "2024-04-05T10:00:15Z",
"change_id": 12345,
"error": "HTTP 503: Service Unavailable"
}
]
}
This health check is polled by a service mesh, a monitoring system, or a dashboard. Alerts trigger when lag exceeds 30 seconds or when error rate exceeds 1%.
This page was rewritten on 18 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.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy