What's Change Data Capture?
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]:::svcWhen 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.
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_CONFIRMEDin 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| AppThis 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.
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| ConsumersThis 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.5pxNow 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.5pxNow 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.
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
endPostgreSQL 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.
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 → CONFIRMED2.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:
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 --> ConsumerSo 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 fail3.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.
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.5pxThe 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:
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;
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 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.
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/ABC1235.2 What Problem Does Each Piece Solve?
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
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.
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 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:
- The booking service updates PostgreSQL.
- PostgreSQL records the change in WAL.
- Debezium captures the change.
- Debezium publishes the CDC event to Kafka.
- 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?
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.