solidcodersolidcoder
Explore Courses
solidcodersolidcoder

Practical courses for software engineering interviews — no gatekeeping, no fluff.

High Level Design

  • High Level Design Concepts

Explore

  • Courses
  • About
  • Privacy Policy
  • Terms

© 2026 solidcoder · Practical courses for software engineering interviews.

Built for the AI era — learn by doing.

Home/High-Level Design/High Level Design Concepts/What's Change Data Capture?
Chapters — High Level Design Concepts▾

What's Change Data Capture?

CDC·Debezium·Kafka·PostgreSQL·WAL·Write-Ahead Log·Logical Decoding·pgoutput·Outbox Pattern·Replication Slot·LSN·Kafka Connect15 min read·Sep 18, 2026

Learn how Change Data Capture (CDC) works step by step with PostgreSQL WAL, logical decoding, Debezium and Kafka. Understand polling, dual writes, replication slots, LSNs, failures, duplicate events, and the Outbox Pattern with a movie booking example.

Imagine you are building a movie-booking system. A user books a seat, and the booking is stored successfully in PostgreSQL. But the booking doesn't matter only to the Booking Service — the Notification Service needs to send a confirmation, the Analytics Service needs to record it.

flowchart TB
  App[Booking<br/>Service]:::svc --> PG[(PostgreSQL<br/>Booking Confirmed)]:::db
  PG --> Notify[Notification<br/>Service]:::svc
  PG --> Analytics[Analytics<br/>Service]:::svc
The core question

When data changes in the database, how do other systems reliably know about that change? This is the problem Change Data Capture (CDC) is designed to solve.

TL;DR

We’ll walk through the different solutions step by step, starting with the simplest approach and gradually moving toward a production-grade solution. The goal is to understand why each approach falls short and what problem the next approach solves.

We’ll see this progression in action:

  • 1. Polling Start by periodically querying the database for changes. We’ll see why this adds database load, introduces latency, and makes reliable change detection — especially deletes — difficult.

  • 2. Dual Write Next, let the application write to the database and publish to Kafka separately. This introduces the dual-write problem: one operation can succeed while the other fails.

  • 3. Transactional Outbox Solve the dual-write problem by writing the business data and an outbox event in the same database transaction. Now the data change and event creation succeed or fail together.

  • 4. CDC + Debezium Finally, eliminate the need to poll the outbox table. Debezium reads PostgreSQL’s durable WAL through logical decoding and streams the committed changes to Kafka.

  • 5. Outbox + CDC Put everything together: the application creates a meaningful event such as BOOKING_CONFIRMED in the transactional outbox, and CDC reliably streams it to Kafka.

Let's walk through why each step leads to the next.

Part 1 — The Problem: Detecting Database Changes

Suppose the bookings table contains booking_id = B123, status = PENDING. A user completes payment, and it becomes status = CONFIRMED. Now other systems need to know: Booking B123 was confirmed. There are a few ways to try to solve this — each with a specific failure mode that motivates the next approach.

1.1 Approach: Poll the Database

The first idea is simple: "just keep checking the database."

SELECT *
FROM bookings
WHERE updated_at > :last_checked_time;
flowchart LR
  Notify[Notification<br/>Service]:::svc -->|Anything changed?| App[Booking<br/>Service]:::svc
  Analytics[Analytics<br/>Service]:::svc -->|Anything changed?| App
  App -->|SELECT| PG[(PostgreSQL)]:::db
  PG -.->|No| App

This is called polling, and it has three real problems.

Problem 1 — Unnecessary database load. If nothing changes for 30 seconds, the application still queries every few seconds regardless:

10:00:00 → SELECT → No changes
10:00:05 → SELECT → No changes
10:00:10 → SELECT → No changes
...
10:00:30 → SELECT → 1 change

Now multiply that by every service doing the same polling against the same database, and the database is being repeatedly queried just to discover whether anything changed at all.

Problem 2 — Change tracking becomes complicated. Reliably capturing every change between poll intervals requires careful logic around timestamps, ordering, concurrent updates, retries, clock precision, pagination, and duplicate processing. This only gets harder as the system grows.

Problem 3 — Deletes are difficult. If Booking B123 is deleted with DELETE FROM bookings WHERE booking_id = 'B123', a later SELECT * FROM bookings simply won't include it — there's no row left to discover a change on. You'd need a separate mechanism entirely (an audit table, a tombstone, a soft delete, or a change history table) just to notice deletions.

Verdict

Polling can work, but it is not a great general-purpose change propagation mechanism.

1.2 Approach: Let the Application Publish the Event

A better idea: the application already knows when it changes something, so why keep checking? On confirming a booking, it can save to PostgreSQL and publish an event to Kafka:

public void confirmBooking(Booking booking) {

    bookingRepository.save(booking);

    kafkaProducer.send(
        "booking-events",
        new BookingConfirmedEvent(booking)
    );
}
flowchart TB
  App[Booking<br/>Service]:::svc -->|write| PG[(PostgreSQL)]:::db
  M3[B125<br/>tail]:::evt
  M2[B124]:::evt
  M1[B123<br/>head]:::evt
  App -->|enqueue| M3

  subgraph Q[Kafka · booking-events]
    direction TB
    M3 -->|next| M2 -->|next| M1
  end

  subgraph Consumers[Consumers]
    direction LR
    Notify[Notification<br/>Service]:::svc
    Analytics[Analytics<br/>Service]:::svc
  end

  M1 -->|dequeue| Consumers

This is far more immediate than polling — but it introduces a different, more subtle problem.

1.3 The Dual-Write Problem

The application is now writing to two different systems, and these writes are not one ordinary local database transaction. Consider saving to PostgreSQL first:

flowchart TB
  App[Booking<br/>Service]:::svc -->|1. Save booking| PG[(PostgreSQL<br/>B123 CONFIRMED)]:::db
  PG -->|2. SUCCESS| App
  M3[B125<br/>tail]:::evt
  M2[B124]:::evt
  M1[B123<br/>head]:::evt
  App -.->|3. Publish fails| M3
  subgraph Q[Kafka · booking-events]
    direction TB
    M3 --> M2 --> M1
  end
  linkStyle 2 stroke:#f87171,stroke-width:1.5px

Now PostgreSQL has B123 = CONFIRMED, but Kafka has no BOOKING_CONFIRMED event at all — the Notification and Analytics services never hear about it.

Reversing the order doesn't fix it either:

flowchart TB
  App[Booking<br/>Service]:::svc -->|1. Publish event| M3
  M3[B125<br/>tail]:::evt
  M2[B124]:::evt
  M1[B123<br/>head]:::evt

  App -.->|2. Save booking · FAIL| PG[(PostgreSQL<br/>nothing stored)]:::db

  subgraph Q[Kafka · booking-events]
    direction TB
    M3 --> M2 --> M1
  end

  linkStyle 1 stroke:#f87171,stroke-width:1.5px

Now Kafka has BOOKING_CONFIRMED, but PostgreSQL never actually stored the booking — a downstream service could send a confirmation for a booking that doesn't exist.

Neither ordering solves it
Order
Failure mode
DB → Kafka
DB succeeds, Kafka fails — event is lost.
Kafka → DB
Kafka succeeds, DB fails — event describes something that never happened.

This is the dual-write problem.

1.4 Why Not Put Both in One Transaction?

A natural question: can't we just wrap the PostgreSQL write and the Kafka write in one transaction?

flowchart TB
  App[Booking<br/>Service]:::svc -.->|one atomic commit?| Tx
  subgraph Tx[Distributed Transaction]
    direction LR
    PG[(PostgreSQL)]:::db
    Kafka[Kafka]:::evt
  end

PostgreSQL and Kafka are separate systems — coordinating commits and rollbacks across them requires distributed transaction machinery and adds considerable complexity. There's a simpler path: instead of making PostgreSQL and Kafka participate in one transaction, make the important write happen entirely inside PostgreSQL's own transaction. That's the Outbox Pattern, covered in Part 3 — but first, we need the technology that lets us observe database changes automatically: CDC.

Part 2 — How CDC Works

So far, we saw that polling is inefficient and application → DB + Kafka creates a dual-write problem.

CDC takes a different approach:

Let the database tell us what changed.

Suppose the booking service confirms booking B123:

UPDATE bookings
SET status = 'CONFIRMED'
WHERE booking_id = 'B123';

2.1 PostgreSQL Records the Change

PostgreSQL writes information about database changes to its Write-Ahead Log (WAL). The WAL is not a list of SQL queries. It is low-level information PostgreSQL uses for recovery and replication.

The important idea: The application updates PostgreSQL, and PostgreSQL records the change in its WAL (Write-Ahead Log). Each position in the WAL is identified by an LSN (Log Sequence Number). You can think of an LSN as a position or bookmark in the WAL.

PostgreSQL
    │
    ↓
   WAL
    │
    ├── LSN 100 → UPDATE booking B123
    ├── LSN 120 → INSERT booking B124
    ├── LSN 140 → UPDATE booking B123
    └── LSN 160 → INSERT outbox event

How does a CDC consumer read the WAL?

A CDC consumer cannot simply read PostgreSQL's WAL files directly.

Instead, PostgreSQL provides logical replication for consumers that need to read changes from the WAL.

Each CDC consumer gets its own replication slot. You can think of the slots as a simple map between a consumer and its current position in the WAL:

Consumer        Replication Slot        LSN
   │                   │                 │
Debezium-1        booking_slot        140
Debezium-2        payment_slot        180
Debezium-3        audit_slot          220

The replication slot is a persistent object maintained by PostgreSQL. It tracks the consumer's progress using an LSN (Log Sequence Number).

The important idea is:

The consumer does not directly read the WAL files. PostgreSQL's logical decoding mechanism processes the WAL from the position tracked by the replication slot.

The replication slot allows the consumer to resume from its previous position if it disconnects or crashes.

Now that we know how the consumer gets changes from the WAL, let's see how logical decoding converts those low-level WAL records into meaningful database changes.

2.2 Logical Decoding Makes WAL Useful

The above diagram is a simplified view. PostgreSQL stores database changes in the WAL, but these records are in PostgreSQL's internal, low-level format.

A CDC system needs these changes in a logical form that describes what actually changed:

booking B123
PENDING → CONFIRMED

So, we need a mechanism that can read the WAL and turn these low-level records into meaningful database changes.

That mechanism is called logical decoding.

Pipeline vocabulary
Term
What it means
WAL
PostgreSQL's low-level log that records database changes.
Logical Decoding
Reads WAL records and converts them into meaningful database changes.
pgoutput
PostgreSQL's standard output plugin that formats these changes into a logical replication stream.
Logical Replication Stream
The stream of decoded database changes that a consumer can read.

Resuming after a disconnect is the replication slot's job — §2.4 covers exactly how that works.

sequenceDiagram
    participant App as Application
    participant PG as PostgreSQL
    participant WAL as WAL
    participant Slot as Replication Slot
    participant LD as Logical Decoding
    participant Stream as Logical Replication Stream
    participant Consumer as CDC Consumer<br/>(Debezium)


    App->>PG: UPDATE booking B123
    PG->>WAL: Record database change
    PG->>PG: Commit transaction

    Consumer->>Slot: Connect to replication slot
    Slot-->>Consumer: Consumer position (LSN 140)

    Consumer ->> LD: Read LSN 140
    LD->>WAL: Process WAL from LSN 140
    LD->>LD: Decode WAL using pgoutput
    LD-->>Stream: get logical change
    Stream ->>LD: return logical change

    LD-->>Consumer: booking B123<br/>PENDING → CONFIRMED

2.3 Here Comes Debezium

We need a component that can continuously consume this WAL logs, turn the changes into structured CDC events, and publish them to Kafka.

That is where Debezium comes in.

2.3.1 What is Debezium?

Debezium is an open-source Change Data Capture (CDC) platform. It captures database changes such as INSERT, UPDATE, and DELETE and converts them into structured events.

For PostgreSQL, Debezium reads changes through PostgreSQL's logical replication / logical decoding mechanism.

But there is one more component involved: Kafka Connect.

2.3.2 Where does Debezium run?

In a typical PostgreSQL → Kafka setup, the Debezium PostgreSQL Connector runs inside Kafka Connect.

Kafka Connect is the runtime that runs connectors and moves data between external systems and Kafka.

So the responsibilities are:

Responsibilities
Component
What it does
PostgreSQL
Stores data and records changes in the WAL.
Logical Decoding
Exposes WAL changes as a logical change stream.
Debezium PostgreSQL Connector
Captures the changes and converts them into CDC events.
Kafka Connect
Runs the Debezium connector and handles the connection to Kafka.
Kafka
Receives and distributes the events to downstream consumers.

The overall architecture looks like this:

flowchart LR
  PG[(PostgreSQL)]:::db
  WAL[WAL]
  LD[Logical Decoding]

  subgraph KC[Kafka Connect]
    DBZ[Debezium<br/>PostgreSQL Connector]
  end

  Kafka[Kafka]:::evt
  Consumer[Downstream Services]:::svc

  PG --> WAL
  WAL --> LD
  LD --> DBZ
  DBZ --> Kafka
  Kafka --> Consumer

So finally we can say, Debezium connects to PostgreSQL's logical replication stream and consumes these changes.

It converts them into structured CDC events:

{
  "operation": "UPDATE",
  "table": "bookings",
  "booking_id": "B123",
  "status": "CONFIRMED"
}

2.4 How Does Debezium Resume?

PostgreSQL uses a replication slot to track the CDC stream position.

The position is represented by an LSN (Log Sequence Number).

Think of it as a bookmark:

WAL
──────────────────────────────→

100   110   120   130   140
                ↑
               LSN

If Debezium restarts, it can continue from the required position instead of starting from the beginning.

The replication slot also tells PostgreSQL which WAL is still needed, so that WAL is retained.

2.5 Finally, Kafka

Debezium can publish the CDC events to Kafka:

flowchart LR
    DB[(PostgreSQL)] --> WAL[WAL]
    WAL --> LD[Logical Decoding]
    LD --> D[Debezium]
    D --> K[Kafka]
    K --> N[Notification]
    K --> A[Analytics]
    K --> C[Cache]

Now one database change can be consumed by many services.

Remember just this:

WAL records the change → logical decoding exposes it → Debezium captures it → Kafka distributes it.

Part 3 — Failure Handling & Operational Concerns

CDC's reliability guarantees only matter once you understand exactly what happens when each piece fails.

3.1 What Happens If Debezium Crashes?

Suppose Debezium has processed through LSN 120 out of a WAL containing 100, 110, 120, 130, 140, and then it crashes. The PostgreSQL replication slot retains the WAL the consumer still needs, so on restart Debezium can continue from LSN 130 onward.

flowchart LR
  PG[(PostgreSQL)]:::db
  WAL[WAL]:::evt
  Deb[Debezium]:::fail

  PG -->|1. WRITE| WAL
  WAL -->|2. READ| Deb
  Deb -.->|3. PUBLISH · FAIL| M3

  subgraph Q[Kafka · booking-events]
    direction TB
    M3[B125<br/>tail]:::evt
    M2[B124]:::evt
    M1[B123<br/>head]:::evt
    M3 --> M2 --> M1
  end

  subgraph L[WAL · Log Sequence Numbers]
    direction LR
    L100["100"] --> L110["110"] --> L120["120"] --> L130["130"] --> L140["140"]
  end

  Deb -.->|Processed through| L120

  linkStyle 1,2 stroke:#ef4444,stroke-width:2px

  classDef fail fill:#fee2e2,stroke:#ef4444,color:#dc2626,stroke-width:2px
  class Deb fail
  class L120 fail

3.2 Can CDC Produce Duplicate Events?

Yes — and this is an important property to design around. If Debezium reads LSN 500, publishes BOOKING_CONFIRMED to Kafka, and then crashes before its latest offset state is safely persisted, it may re-read that same change after restarting. The downstream consumer could then see BOOKING_CONFIRMED twice.

Design CDC consumers to be idempotent

One common technique: include a unique event_id (e.g. E100) on every event. The consumer keeps a record of processed IDs; if the same event_id arrives again, it's recognized as already-processed and simply ignored.

3.3 What If Kafka Is Down?

The database keeps accepting transactions and writing to WAL regardless of Kafka's availability — Debezium just can't successfully publish to Kafka in the meantime:

flowchart LR
  PG[(PostgreSQL)]:::db
  WAL[WAL]:::evt
  Deb[Debezium]:::svc

  PG -->|1. WRITE| WAL
  WAL -->|2. READ| Deb

  Deb -.->|3. Publish event · FAIL| M3

  subgraph Q[Kafka · booking-events]
    direction TB
    M3[B125<br/>tail]:::evt
    M2[B124]:::evt
    M1[B123<br/>head]:::evt
    M3 --> M2 --> M1
  end

  linkStyle 2 stroke:#f87171,stroke-width:1.5px

The replication slot retains the WAL Debezium still needs, so once Kafka comes back, Debezium resumes publishing the accumulated changes:

One operational warning: if a consumer stays down too long, retained WAL keeps growing and can fill PostgreSQL's disk — monitor replication-slot lag.

Part 4 — From Database Rows to Business Events: The Outbox Pattern

4.1 We Have CDC — So Are We Done?

Not quite. CDC solves how do we reliably capture database changes, but there's a second question: what should we actually publish to Kafka? Consider a real booking transaction:

BEGIN;

UPDATE bookings
SET status = 'CONFIRMED';

UPDATE seats
SET status = 'BOOKED';

UPDATE payments
SET status = 'SUCCESS';

COMMIT;

CDC can capture every one of these row-level changes — but a downstream service usually doesn't care about individual updates. It just needs BOOKING_CONFIRMED. That's a business event, and the distinction matters:

Two different kinds of event
Kind
Example
Database event
"row X was updated"
Business event
"Booking B123 was confirmed"

Raw CDC gives us database changes; the application understands the business meaning. Combining these two is exactly what the Outbox Pattern does.

4.2 The Outbox Pattern

Recall the dual-write problem: the application wanted to update PostgreSQL and publish to Kafka as two independent writes. The Outbox Pattern changes the design — instead of writing to two systems separately, both the business data and the event are written inside the same PostgreSQL transaction:

BEGIN;

UPDATE bookings
SET status = 'CONFIRMED'
WHERE booking_id = 'B123';

INSERT INTO outbox_events (
    event_id,
    event_type,
    aggregate_id,
    payload
)
VALUES (
    'E100',
    'BOOKING_CONFIRMED',
    'B123',
    '{"bookingId":"B123"}'
);

COMMIT;
The key advantage

If the transaction commits, both the booking update and the outbox event exist. If it rolls back, neither does. The atomicity boundary has moved to a single system — PostgreSQL — so there's no longer any need for one transaction spanning PostgreSQL and Kafka.

Outbox and CDC solve different halves of the problem

Outbox gives us a reliable place to store the business event. CDC moves that event from the database to Kafka. They aren't competing solutions:

Outbox = What business event should be published?
CDC    = How do we reliably move the database change out?
Kafka  = How do we distribute the event to consumers?

4.3 Tracing the WAL for This Transaction

The same transaction from 4.2, as it lands in WAL:

Logical decoding exposes both changes to the replication stream, and Debezium receives them — but we don't want Kafka to receive every raw row change, just the meaningful business event. That's the job of the Outbox Event Router.

4.4 The Outbox Event Router

Debezium can be configured to watch the outbox table specifically and apply its Outbox Event Router transformation:

A typical outbox row carries fields like:

event_id       = E100
aggregate_type = booking
aggregate_id   = B123
event_type     = BOOKING_CONFIRMED
payload        = {"bookingId":"B123"}

The router transforms that database record into the actual Kafka event — so instead of Kafka seeing a raw UPDATE outbox_events, it receives the clean business event BOOKING_CONFIRMED. The event_id doubles as the deduplication key consumers use to detect duplicate delivery. Full option reference: Outbox Event Router.

Raw CDC vs. Outbox CDC
Approach
Event meaning
Raw CDC
"The bookings row changed" — e.g. status: PENDING → CONFIRMED.
Outbox + CDC
"Booking B123 was confirmed" — a structured {eventId, eventType, bookingId} event.

The second is much closer to what downstream business services actually need.

Part 5 — Putting It All Together

5.1 The Complete Movie Booking Flow

The same journey as a sequence diagram:

sequenceDiagram
  participant User
  participant App as Booking Service
  participant DB as PostgreSQL
  participant Slot as Replication Slot
  participant Deb as Debezium
  participant Kafka

  User->>App: Confirm booking B123

  App->>DB: BEGIN
  App->>DB: UPDATE bookings
  App->>DB: INSERT outbox
  App->>DB: COMMIT

  DB->>DB: Write changes to WAL
  DB->>Slot: WAL available at LSN 0/ABC123

  Deb->>Slot: Read from last acknowledged LSN
  Slot-->>Deb: WAL changes + LSN 0/ABC123

  Deb->>Deb: Decode via pgoutput
  Deb->>Kafka: BOOKING_CONFIRMED

  Deb->>Slot: Acknowledge LSN 0/ABC123
  Slot->>DB: confirmed_flush_lsn = 0/ABC123
End-to-end flow
Step
What happens
1. Request received
User confirms a booking; the request reaches the Booking Service.
2. Transaction begins
The Booking Service opens a database transaction.
3. Update the booking
bookings.B123 is set to CONFIRMED.
4. Write the outbox event
outbox_events gets a new row: E100 / BOOKING_CONFIRMED / B123.
5. Commit
Both the booking state and the business event now exist together, atomically.
6. WAL records the change
PostgreSQL writes both changes to its Write-Ahead Log.
7. Logical decoding
WAL is exposed as a logical change stream via pgoutput.
8. Debezium consumes the stream
Debezium reads the logical replication stream.
9. Outbox Event Router runs
The outbox_events record is transformed into a clean business event.
10. Kafka receives the event
BOOKING_CONFIRMED lands in Kafka.
11. Consumers react
Notification, Analytics, and Cache services each process the event independently.

5.2 What Problem Does Each Piece Solve?

One job per component
Component
Problem it solves
Polling
Basic way to discover changes.
Application events
Immediate event publishing.
Outbox
Prevents DB + Kafka dual-write inconsistency.
WAL
PostgreSQL's durable change log.
Logical decoding
Converts WAL into a logical change stream.
pgoutput
Produces PostgreSQL's logical replication stream.
Replication slot
Retains changes needed by the CDC consumer.
LSN
Identifies a position in the WAL stream.
Debezium
Reads the database change stream.
Kafka Connect
Runs the Debezium connector.
Outbox Event Router
Converts outbox records into business events.
Kafka
Stores and distributes events.
Consumers
React to those events.
The key idea

The application writes the business state and the business event atomically to PostgreSQL. CDC then moves the event out of PostgreSQL without requiring the application to perform a second, independent write to Kafka.

5.3 How the Architecture Evolved

Three approaches, compared
Approach
Verdict
1. Polling
Application repeatedly asks PostgreSQL "anything changed?" — simple, but inefficient and difficult to make reliable at scale.
2. Application dual write
Application writes to PostgreSQL and Kafka separately — fast, but vulnerable to the dual-write problem.
3. Outbox + CDC
Application writes business data and the outbox event in one PostgreSQL transaction; CDC (via Debezium) moves the event to Kafka. The application only has one transactional system to write to.
The clean mental model

Outbox solves "what event should be published atomically?" CDC solves "how do we reliably capture that database change?" Debezium implements the CDC pipeline. Kafka distributes the resulting events to downstream consumers.

That is the core of how modern database-to-event pipelines are built.

Check your understanding

A booking transaction commits successfully, but Kafka is temporarily unavailable. Where is the BOOKING_CONFIRMED event now? What prevents PostgreSQL from discarding the required change? Where does Debezium resume from when Kafka becomes available again?

Frequently Asked Questions

What is Change Data Capture (CDC)?

Change Data Capture (CDC) is a technique for continuously capturing changes made to a database, such as INSERT, UPDATE, and DELETE, and making those changes available to downstream systems as events.

Instead of repeatedly asking the database whether something changed, CDC lets the database's change stream tell us what changed. For PostgreSQL, CDC reads changes from the WAL through logical decoding.

Read more: §2.1 PostgreSQL Records the Change.

Why use CDC instead of polling?

With polling, an application repeatedly queries the database to check whether something has changed. For example:

SELECT *
FROM bookings
WHERE updated_at > :last_checked_time;

This creates unnecessary database load when there are no changes and requires additional logic to reliably track ordering, timestamps, retries, duplicates, and deletes. CDC captures changes from the database's change stream as they happen instead.

Read more: §1.1 Approach: Poll the Database.

What is the dual-write problem?

The dual-write problem occurs when an application writes to two independent systems as part of one logical operation:

Application
    ├── Write → PostgreSQL
    └── Publish → Kafka

These two operations are not automatically one atomic transaction. If PostgreSQL succeeds but Kafka fails, the database contains the change but Kafka does not contain the corresponding event. If Kafka succeeds but PostgreSQL fails, Kafka contains an event for data that was never successfully stored.

Read more: §1.3 The Dual-Write Problem.

Does CDC solve the dual-write problem?

No. CDC solves the problem of capturing database changes, but it does not by itself make a database write and a Kafka write atomic. The Transactional Outbox Pattern solves the atomicity problem: the application writes both the business data and the business event into PostgreSQL in the same transaction, and CDC then captures the outbox change and moves it to Kafka.

Read more: §1.3 The Dual-Write Problem and §4.2 The Outbox Pattern.

What is the Transactional Outbox Pattern?

The Transactional Outbox Pattern stores a business event in an outbox table as part of the same database transaction that changes the business data. For example:

BEGIN;

UPDATE bookings
SET status = 'CONFIRMED'
WHERE booking_id = 'B123';

INSERT INTO outbox_events (
    event_id,
    event_type,
    aggregate_id,
    payload
)
VALUES (
    'E100',
    'BOOKING_CONFIRMED',
    'B123',
    '{"bookingId":"B123"}'
);

COMMIT;

If the transaction commits, both the booking update and the outbox event exist. If it rolls back, neither exists. CDC can then capture the outbox event and publish it to Kafka.

Read more: §4.2 The Outbox Pattern.

What is the difference between Outbox and CDC?

They solve different problems:

Outbox → What business event should be stored atomically?
CDC    → How do we reliably capture the database change?
Debezium → Implements the CDC pipeline.
Kafka  → Distributes the resulting events.

The Outbox Pattern gives us a reliable business event inside the database, while CDC provides the mechanism for moving that change out of the database.

Read more: §4.1 We Have CDC — So Are We Done?.

What is PostgreSQL WAL?

WAL (Write-Ahead Log) is PostgreSQL's durable log of database changes. When data changes, PostgreSQL records the necessary information in the WAL. The WAL is not simply a list of SQL queries — it contains low-level information PostgreSQL uses for purposes such as recovery and replication. CDC uses the WAL as the source from which database changes are captured.

PostgreSQL reference: WAL introduction.

Read more: §2.1 PostgreSQL Records the Change.

What is logical decoding in PostgreSQL?

PostgreSQL's WAL contains low-level database information. Logical decoding converts those WAL records into a logical change stream that CDC systems can consume. For example:

WAL
 │
 ↓
Logical Decoding
 │
 ↓
booking B123
PENDING → CONFIRMED

For PostgreSQL, pgoutput is the standard logical replication output plugin used to produce the logical replication stream.

PostgreSQL reference: logical decoding explained.

Read more: §2.2 Logical Decoding Makes WAL Useful.

What is Debezium?

Debezium is an open-source Change Data Capture (CDC) platform. It captures database changes such as INSERT, UPDATE, and DELETE and converts them into structured CDC events. For PostgreSQL, Debezium reads changes through PostgreSQL's logical replication / logical decoding mechanism. In a typical PostgreSQL → Kafka architecture, the Debezium PostgreSQL Connector runs inside Kafka Connect.

Read more: §2.3 Here Comes Debezium.

What is Kafka Connect?

Kafka Connect is a runtime/framework for running connectors that move data between external systems and Kafka. In this architecture, Kafka Connect runs the Debezium connector, while the Debezium connector knows how to capture changes from PostgreSQL. Framework details: Kafka documentation. A useful mental model is:

Kafka Connect = Runtime
Debezium      = Connector
Kafka         = Event streaming system

Read more: §2.3 Here Comes Debezium.

Where does Kafka Connect run?

Kafka Connect runs as a separate process or service in your infrastructure — it does not have to run on the same machine as PostgreSQL:

PostgreSQL host (PostgreSQL + WAL)
        ↓
Kafka Connect host (Kafka Connect + Debezium)
        ↓
Kafka

Read more: §2.3 Here Comes Debezium.

What is an LSN in PostgreSQL?

LSN (Log Sequence Number) identifies a position in PostgreSQL's WAL. You can think of an LSN as a bookmark in the WAL:

WAL

100 ── 110 ── 120 ── 130 ── 140
              ↑
             LSN

CDC systems use this position to keep track of how far they have progressed through the database change stream.

Read more: §2.4 How Does Debezium Resume?.

What is a PostgreSQL replication slot?

A replication slot allows PostgreSQL to keep track of the progress of a logical replication consumer such as Debezium. It also prevents PostgreSQL from discarding WAL that the consumer still needs. For example:

WAL

100 ── 110 ── 120 ── 130 ── 140
              ↑
       Consumer progress

If Debezium has not yet processed later changes, PostgreSQL retains the required WAL so Debezium can continue from the appropriate position.

Read more: §2.4 How Does Debezium Resume?.

What happens if Debezium crashes?

If Debezium crashes, it resumes from its last processed position rather than starting over: the replication slot retains the WAL Debezium still needs, and the LSN identifies the position. Conceptually: processed through LSN 120 → crash → restart → continue from the retained WAL.

Read more: §3.1 What Happens If Debezium Crashes?.

Can CDC produce duplicate events?

Yes. A CDC pipeline can deliver the same change more than once — for example, Debezium may publish an event to Kafka and then crash before its progress is persisted, reprocessing the same change after restart. Downstream consumers should therefore be idempotent. One common approach is a unique event_id:

{
  "event_id": "E100",
  "event_type": "BOOKING_CONFIRMED",
  "booking_id": "B123"
}

The consumer keeps track of processed event IDs and ignores repeats.

Read more: §3.2 Can CDC Produce Duplicate Events?.

What happens if Kafka is temporarily unavailable?

The database keeps accepting transactions and recording changes in the WAL. Debezium cannot publish while Kafka is down, but the replication slot retains the WAL it still needs — once Kafka returns, Debezium publishes the accumulated changes. Operational caveat: if the consumer stays behind too long, retained WAL can fill PostgreSQL disk, so replication-slot lag should be monitored.

Read more: §3.3 What If Kafka Is Down?.

What is the difference between a database event and a business event?

A database event describes a change to database state (bookings row changed, status: PENDING → CONFIRMED). A business event describes what happened in domain terms (BOOKING_CONFIRMED, booking_id = B123). Raw CDC captures database changes; the Outbox Pattern lets the application create meaningful business events such as BOOKING_CONFIRMED.

Read more: §4.1 We Have CDC — So Are We Done?.

Why use Outbox + CDC instead of sending directly to Kafka?

Sending directly to Kafka creates the dual-write problem (two independent writes, no shared atomicity). With Outbox + CDC, the application performs one atomic database transaction containing both the business data and the outbox event — then CDC moves the event from PostgreSQL to Kafka:

  1. The booking service updates PostgreSQL.
  2. PostgreSQL records the change in WAL.
  3. Debezium captures the change.
  4. Debezium publishes the CDC event to Kafka.
  5. Multiple consumers independently react to that event.

Read more: §4.2 The Outbox Pattern and §5.1 The Complete Movie Booking Flow.

What is the complete PostgreSQL → Kafka CDC flow?
The complete flow in one line

Outbox defines the business event → WAL records the database change → logical decoding exposes it → Debezium captures it → Kafka Connect runs the connector → Kafka distributes the event.

See it end to end: §5.1 The Complete Movie Booking Flow.

Can one Debezium connector cover multiple tables or databases?

One connector connects to one database but captures many tables via table.include.list — each table streams to its own topic through one replication slot. A different database needs a separate connector (the same Kafka Connect cluster can run both).

Read more: §2.4 How Does Debezium Resume?.

What do you configure in the Outbox Event Router?

Four things: the outbox table to watch (table.include.list), the routing field (route.by.field, e.g. aggregate_id), the topic name (route.topic.replacement), and payload handling (table.expand.json.payload). Together they turn a raw outbox row into a clean BOOKING_CONFIRMED event on the right topic.

All router options: Outbox Event Router reference.

Read more: §4.4 The Outbox Event Router.

How fast is Debezium, and can reads run in parallel?

Latency is tuned with three knobs: poll.interval.ms (how often it checks for changes, default 500ms), max.batch.size (events per poll, default 2048), and max.queue.size (in-memory buffer, default 8192). But one connector is a single ordered stream — raising tasks.max does not parallelize it. Real throughput comes from splitting tables across multiple connectors, each with its own replication slot — and PostgreSQL retains WAL for the slowest slot, so monitor every slot's lag.

Read more: §2.4 How Does Debezium Resume?. Full knob reference: Debezium PostgreSQL connector documentation.

Part of a free guide

High Level Design Concepts

Core building blocks behind scalable systems, explained with practical examples.

Browse All Guides →
On this page
  • Part 1 — The Problem: Detecting Database Changes
    • 1.1 Approach: Poll the Database
    • 1.2 Approach: Let the Application Publish the Event
    • 1.3 The Dual-Write Problem
    • 1.4 Why Not Put Both in One Transaction?
  • Part 2 — How CDC Works
    • 2.1 PostgreSQL Records the Change
    • 2.2 Logical Decoding Makes WAL Useful
    • 2.3 Here Comes Debezium
    • 2.4 How Does Debezium Resume?
    • 2.5 Finally, Kafka
  • Part 3 — Failure Handling & Operational Concerns
    • 3.1 What Happens If Debezium Crashes?
    • 3.2 Can CDC Produce Duplicate Events?
    • 3.3 What If Kafka Is Down?
  • Part 4 — From Database Rows to Business Events: The Outbox Pattern
    • 4.1 We Have CDC — So Are We Done?
    • 4.2 The Outbox Pattern
    • 4.3 Tracing the WAL for This Transaction
    • 4.4 The Outbox Event Router
  • Part 5 — Putting It All Together
    • 5.1 The Complete Movie Booking Flow
    • 5.2 What Problem Does Each Piece Solve?
    • 5.3 How the Architecture Evolved
  • Frequently Asked Questions