jsonkafkacdcdebeziumdata-engineering

Change Data Capture: JSON Events with Debezium & Kafka

·10 min read·Advanced

Turning a database into a stream of JSON events

Change Data Capture (CDC) reads a database's internal transaction log — the same log used for replication and crash recovery — and turns every insert, update, and delete into a JSON event on a message stream. Instead of polling "what changed since I last checked?", downstream systems (search indexes, caches, data warehouses, other microservices) simply subscribe to a Kafka topic and react to each change as JSON, in near real time, with no extra load on the source database beyond reading a log it already maintains.

Debezium is the dominant open-source CDC platform, and its event shape has become a de facto standard that most modern data pipelines are built around.

The anatomy of a Debezium change event

Every CDC event carries the before state, the after state, and metadata about the source and the operation:

json
{
  "before": {
    "id": 1042,
    "status": "pending",
    "total": 149.97
  },
  "after": {
    "id": 1042,
    "status": "shipped",
    "total": 149.97
  },
  "source": {
    "connector": "postgresql",
    "db": "orders_db",
    "table": "orders",
    "ts_ms": 1751800000000,
    "lsn": 289471200
  },
  "op": "u",
  "ts_ms": 1751800000123
}
FieldMeaning
beforeThe row's state before the change — null for an insert
afterThe row's state after the change — null for a delete
opOperation type: c create, u update, d delete, r initial snapshot read
sourceWhich database, table, and transaction log position produced this event
ts_msWhen Debezium processed the change

The before/after pair is what makes CDC events genuinely useful beyond "something changed" — a consumer can see *exactly* which fields changed by diffing the two objects, without a separate query back to the source database.

Why op matters: three very different events, one shape

The same JSON structure represents inserts, updates, and deletes — only before, after, and op change:

json
// Insert (op: "c") — before is null
{ "before": null, "after": { "id": 1043, "status": "pending" }, "op": "c" }
json
// Delete (op: "d") — after is null
{ "before": { "id": 1043, "status": "pending" }, "after": null, "op": "d" }

A naive consumer that only looks at after will crash on a delete event, since after is null — always branch on op first.

Why CDC beats polling

Polling ("run this query every 30 seconds and diff the results") has three real costs: it adds recurring load on the database for data that usually hasn't changed, it introduces up-to-30-second staleness by design, and it requires the source table to have a reliable "updated_at" column to poll against — which not every table has. CDC instead taps the transaction log the database is *already writing* for its own durability guarantees, so capturing changes adds negligible extra load and delivers events within milliseconds of the commit.

A consumer processing the stream

javascript
// Node.js — KafkaJS consumer
import { Kafka } from "kafkajs";

const kafka = new Kafka({ brokers: ["kafka:9092"] });
const consumer = kafka.consumer({ groupId: "search-indexer" });

await consumer.subscribe({ topic: "orders_db.public.orders" });
await consumer.run({
  eachMessage: async ({ message }) => {
    const event = JSON.parse(message.value.toString());
    if (event.op === "d") {
      await searchIndex.delete(event.before.id);
    } else {
      await searchIndex.upsert(event.after); // covers both create and update
    }
  },
});

Common real-world uses

  • Keeping a search index in sync — Elasticsearch or Algolia updated the moment a product row changes, with no batch job or cron delay.
  • Cache invalidation — evict or refresh a Redis-cached record the instant its source row changes, instead of relying on a TTL alone.
  • Feeding a data warehouse — stream operational database changes into Snowflake or BigQuery for analytics without a nightly batch ETL job.
  • Microservice data replication — one service owns a table; other services subscribe to its CDC stream to maintain their own read-optimized copy, avoiding a synchronous cross-service call on every read.
  • Audit logging — the before/after pair is a ready-made audit trail of exactly what changed and when.

Gotchas worth knowing

  • Schema changes ripple through. Adding a column to the source table changes the shape of every future event — consumers need to tolerate new, unexpected fields gracefully (don't use strict/additionalProperties: false schemas for CDC consumers).
  • The initial snapshot is a special case. When Debezium first connects to an existing table, it emits a read (op: "r") event per existing row before switching to live c/u/d events — consumers need to handle the snapshot phase distinctly if ordering matters.
  • Numeric precision. Database-specific numeric types (like PostgreSQL's numeric) sometimes serialize as a string or a special encoded structure in Debezium's default JSON converter rather than a plain JSON number, to avoid floating-point precision loss — check your connector's numeric handling mode.

Frequently asked questions

CDC turns database row changes into a stream of events by reading the database's transaction log, so downstream systems can react to inserts, updates, and deletes in near real time without polling.

It's the operation type: c for create/insert, u for update, d for delete, and r for a row emitted during Debezium's initial snapshot of existing data.

Because there was no prior row state — an insert only has an "after" state. Symmetrically, "after" is null on a delete, since the row no longer exists.

Paste a raw Kafka message value into JSONKit's JSON Formatter to read the nested before/after/source structure clearly, or the JSON Diff tool to see exactly which fields changed between before and after.

Try JSONKit — JSON Developer Tools

Format, validate, convert and diff JSON. Instant, accurate and 100% private.