Skip to content

Implementing Database Change Data Capture Pipelines

Build CDC pipelines that stream database changes downstream in real time: log-based capture with Debezium, event formats, schema evolution, exactly-once.

5 min read
Data flow diagram showing database transaction logs being captured and streamed to multiple downstream consumers through a CDC pipeline

Polling a database every few seconds to detect changes wastes resources and introduces latency. Change Data Capture (CDC) reads the database's own transaction log—the write-ahead log (WAL) in PostgreSQL, the binlog in MySQL—and streams every insert, update, and delete as an event. This gives you real-time data replication without impacting the source database's performance.

CDC is the backbone of modern data architectures: it powers search index updates, cache invalidation, analytics pipelines, and cross-service data synchronization without coupling producers to consumers.

Log-Based CDC with Debezium

Debezium reads the database transaction log and publishes change events to Kafka. The source database doesn't need any modification—no triggers, no polling queries, no application-level event publishing.

ymlyaml
# ❌ Polling-based approach — high latency, wasteful queries
# SELECT * FROM orders
# WHERE updated_at > :last_check_time
# Run every 5 seconds across all tables
# Misses deletes, creates load on source database
tstypescript
// ✅ Debezium connector configuration for PostgreSQL CDC
interface DebeziumConnectorConfig {
  name: string;
  config: {
    "connector.class": string;
    "database.hostname": string;
    "database.port": number;
    "database.user": string;
    "database.dbname": string;
    "database.server.name": string;
    "plugin.name": string;
    "slot.name": string;
    "publication.name": string;
    "table.include.list": string;
    "transforms": string;
    "transforms.unwrap.type": string;
    "transforms.unwrap.drop.tombstones": string;
    "key.converter": string;
    "value.converter": string;
    "value.converter.schemas.enable": string;
  };
}
 
const ordersCdcConnector: DebeziumConnectorConfig = {
  name: "orders-cdc-connector",
  config: {
    "connector.class":
      "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "db.internal",
    "database.port": 5432,
    "database.user": "cdc_reader",
    "database.dbname": "production",
    "database.server.name": "prod-orders",
    "plugin.name": "pgoutput",
    "slot.name": "orders_slot",
    "publication.name": "orders_publication",
    "table.include.list": "public.orders,public.order_items",
    "transforms": "unwrap",
    "transforms.unwrap.type":
      "io.debezium.transforms.ExtractNewRecordState",
    "transforms.unwrap.drop.tombstones": "false",
    "key.converter":
      "org.apache.kafka.connect.json.JsonConverter",
    "value.converter":
      "org.apache.kafka.connect.json.JsonConverter",
    "value.converter.schemas.enable": "false",
  },
};

The pgoutput plugin reads PostgreSQL's logical replication stream. The ExtractNewRecordState transform flattens Debezium's envelope format into the after-state of each row, simplifying downstream processing.

Processing Change Events

Each CDC event contains the operation type, the changed data, and metadata about the source. Here's how to consume and route these events.

tstypescript
interface CdcEvent {
  op: "c" | "u" | "d" | "r"; // create, update, delete, read (snapshot)
  before: Record<string, unknown> | null;
  after: Record<string, unknown> | null;
  source: {
    version: string;
    connector: string;
    name: string;
    ts_ms: number;
    db: string;
    schema: string;
    table: string;
    lsn: number;
  };
  ts_ms: number;
}
 
type EventHandler = (event: CdcEvent) => Promise<void>;
 
class CdcEventRouter {
  private handlers: Map<string, EventHandler[]> = new Map();
 
  on(table: string, handler: EventHandler): void {
    const existing = this.handlers.get(table) ?? [];
    existing.push(handler);
    this.handlers.set(table, existing);
  }
 
  async process(event: CdcEvent): Promise<void> {
    const table = event.source.table;
    const handlers = this.handlers.get(table) ?? [];
 
    for (const handler of handlers) {
      try {
        await handler(event);
      } catch (error) {
        console.error(
          `Handler failed for ${table}:`,
          error
        );
        // Dead letter queue for failed events
        await this.sendToDeadLetter(event, error as Error);
      }
    }
  }
 
  private async sendToDeadLetter(
    event: CdcEvent,
    error: Error
  ): Promise<void> {
    console.error(
      JSON.stringify({
        type: "dead_letter",
        table: event.source.table,
        operation: event.op,
        lsn: event.source.lsn,
        error: error.message,
        timestamp: new Date().toISOString(),
      })
    );
  }
}
 
// Wire up handlers for different tables
const router = new CdcEventRouter();
 
router.on("orders", async (event) => {
  if (event.op === "c" || event.op === "u") {
    await updateSearchIndex("orders", event.after);
    await invalidateCache(`order:${event.after?.id}`);
  }
 
  if (event.op === "d") {
    await removeFromSearchIndex("orders", event.before?.id);
    await invalidateCache(`order:${event.before?.id}`);
  }
});
 
router.on("order_items", async (event) => {
  if (event.op === "c") {
    await updateAnalytics("new_item", event.after);
  }
});

Handling Schema Evolution

Database schemas change. Columns get added, renamed, or dropped. Your CDC pipeline must handle these changes without breaking downstream consumers.

tstypescript
interface SchemaVersion {
  version: number;
  table: string;
  columns: Map<string, ColumnDef>;
  migrations: SchemaMigration[];
}
 
interface ColumnDef {
  name: string;
  type: string;
  nullable: boolean;
  defaultValue?: unknown;
}
 
interface SchemaMigration {
  fromVersion: number;
  toVersion: number;
  transform: (record: Record<string, unknown>) => Record<string, unknown>;
}
 
class SchemaRegistry {
  private schemas: Map<string, SchemaVersion[]> = new Map();
 
  register(schema: SchemaVersion): void {
    const existing = this.schemas.get(schema.table) ?? [];
    existing.push(schema);
    existing.sort((a, b) => a.version - b.version);
    this.schemas.set(schema.table, existing);
  }
 
  evolve(
    table: string,
    record: Record<string, unknown>,
    fromVersion: number,
    toVersion: number
  ): Record<string, unknown> {
    const versions = this.schemas.get(table);
    if (!versions) return record;
 
    let current = { ...record };
 
    for (const schema of versions) {
      for (const migration of schema.migrations) {
        if (
          migration.fromVersion >= fromVersion &&
          migration.toVersion <= toVersion
        ) {
          current = migration.transform(current);
        }
      }
    }
 
    return current;
  }
}
 
// Example: handling a column rename
const registry = new SchemaRegistry();
registry.register({
  version: 2,
  table: "orders",
  columns: new Map([
    ["id", { name: "id", type: "uuid", nullable: false }],
    [
      "customer_email",
      { name: "customer_email", type: "text", nullable: false },
    ],
  ]),
  migrations: [
    {
      fromVersion: 1,
      toVersion: 2,
      transform: (record) => {
        // Column renamed: email → customer_email
        const { email, ...rest } = record;
        return { ...rest, customer_email: email };
      },
    },
  ],
});

Exactly-Once Delivery Semantics

CDC events must be processed exactly once. Duplicate processing leads to incorrect counts, duplicate notifications, and data inconsistency.

tstypescript
interface ProcessedEvent {
  eventId: string;
  lsn: number;
  processedAt: Date;
}
 
class IdempotentProcessor {
  private processedEvents: Map<string, ProcessedEvent> = new Map();
 
  private generateEventId(event: CdcEvent): string {
    // Unique ID from source position + table + operation
    return `${event.source.name}:${event.source.lsn}:${event.source.table}:${event.op}`;
  }
 
  async processOnce(
    event: CdcEvent,
    handler: (event: CdcEvent) => Promise<void>
  ): Promise<{ processed: boolean; reason?: string }> {
    const eventId = this.generateEventId(event);
 
    // Check if already processed
    if (this.processedEvents.has(eventId)) {
      return {
        processed: false,
        reason: "duplicate",
      };
    }
 
    try {
      // Process within a transaction that also records
      // the event as processed
      await handler(event);
 
      this.processedEvents.set(eventId, {
        eventId,
        lsn: event.source.lsn,
        processedAt: new Date(),
      });
 
      return { processed: true };
    } catch (error) {
      // Don't mark as processed — allow retry
      throw error;
    }
  }
 
  getLastProcessedLsn(): number {
    let maxLsn = 0;
    for (const event of this.processedEvents.values()) {
      if (event.lsn > maxLsn) maxLsn = event.lsn;
    }
    return maxLsn;
  }
}

Monitoring CDC Pipeline Health

A CDC pipeline that silently falls behind is worse than no CDC at all—downstream systems serve stale data without knowing it.

tstypescript
interface CdcPipelineMetrics {
  replicationLag: number; // milliseconds
  eventsPerSecond: number;
  lastProcessedLsn: number;
  lastEventTimestamp: Date;
  errorCount: number;
  deadLetterCount: number;
}
 
class CdcMonitor {
  private metrics: CdcPipelineMetrics = {
    replicationLag: 0,
    eventsPerSecond: 0,
    lastProcessedLsn: 0,
    lastEventTimestamp: new Date(),
    errorCount: 0,
    deadLetterCount: 0,
  };
 
  private eventTimestamps: number[] = [];
 
  recordEvent(event: CdcEvent): void {
    const now = Date.now();
    const eventTime = event.source.ts_ms;
 
    this.metrics.replicationLag = now - eventTime;
    this.metrics.lastProcessedLsn = event.source.lsn;
    this.metrics.lastEventTimestamp = new Date(eventTime);
 
    // Track throughput over sliding window
    this.eventTimestamps.push(now);
    const windowStart = now - 60_000;
    this.eventTimestamps = this.eventTimestamps.filter(
      (t) => t > windowStart
    );
    this.metrics.eventsPerSecond =
      this.eventTimestamps.length / 60;
  }
 
  checkHealth(): {
    healthy: boolean;
    alerts: string[];
  } {
    const alerts: string[] = [];
 
    if (this.metrics.replicationLag > 30_000) {
      alerts.push(
        `High replication lag: ${this.metrics.replicationLag}ms`
      );
    }
 
    const timeSinceLastEvent =
      Date.now() - this.metrics.lastEventTimestamp.getTime();
    if (timeSinceLastEvent > 300_000) {
      alerts.push(
        `No events for ${Math.round(timeSinceLastEvent / 1000)}s`
      );
    }
 
    if (this.metrics.deadLetterCount > 100) {
      alerts.push(
        `${this.metrics.deadLetterCount} events in dead letter queue`
      );
    }
 
    return {
      healthy: alerts.length === 0,
      alerts,
    };
  }
}

Key Takeaways

Change Data Capture turns your database's transaction log into a real-time event stream without burdening the source with polling queries or trigger overhead. Debezium provides a production-grade connector that reads PostgreSQL WAL or MySQL binlog and publishes structured events to Kafka. Route events by table and operation type, handling creates, updates, and deletes with distinct logic for each downstream system. Plan for schema evolution from the start—register schema versions and define migration transforms that keep older events compatible with newer consumers. Enforce exactly-once processing through idempotency checks keyed on the event's log sequence number, preventing duplicate downstream effects on retries. Monitor replication lag continuously because a CDC pipeline that silently falls behind delivers stale data without warning, which is worse than having no pipeline at all.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX