Is CDC Exactly-Once? Check Each Hop
Debezium says at-least-once, a vendor says exactly-once. Both can be true of different hops. Scope the claim, know what the opt-in Kafka Connect mode really covers, and verify what your pipeline gives.
Read the sentence before you trust it
Debezium's documentation says it provides at-least-once delivery. A vendor page says its service delivers “with exactly-once semantics”. Both can be true, because a guarantee belongs to a hop, not to a pipeline. This page is the companion to Exactly-Once Semantics: that page gives the summary table and the idempotent-consumer patterns, and this one goes down to the settings, shows what each guarantee covers, and ends with how to check what your pipeline gives.
Five questions scope any exactly-once sentence:
- Which hop? Database to connector, connector to topic, topic to topic, topic to sink, or the sink's own store.
- Which producer or connector, and is the opt-in switched on? Many guarantees are off by default.
- Which sink? Does it share a transaction with the offsets, or only receive a call?
- What happens on failover? A crash, a restart, a rebalance, a second worker that thinks it owns the task.
- What dedupes, on which key, ordered by what? The answer that matters at the end is a primary key and a source log position, not a timestamp.
| Phrase | What it leaves open |
|---|---|
| “delivers events to your destination with exactly-once semantics” | Which hop, and which destination. Is the destination write idempotent, or does the service only retry until it is acknowledged? |
| “restart from the last successfully processed LSN, guaranteeing that every change is captured exactly once without data loss or duplication” | Captured by what, and processed where? A restart position makes the read side resumable; it says nothing about duplicates that the restart itself can cause, or about the destination. |
| “Exactly-once processing” (stream processors) | Exactly-once inside which system? For Kafka Streams it is Kafka topics and state stores, not an external database. |
| “Transactional” | Whose transaction, spanning what? A Kafka transaction does not include your database. |
| “Idempotent” | Idempotent on which key, and does a replay of an older change overwrite a newer row? |
| “No duplicates” | Visible to whom? A default Kafka consumer reads records from aborted transactions. |
The first two phrases come from Streamkap's SQL Server CDC guide and Google Cloud's change data capture explainer (read 2026-10-09). They are examples of wording that leaves the hop and the mechanism open, not claims we found to be wrong. Streamkap's sentence names a destination but no mechanism, and its page does not mention at-least-once delivery or idempotent writes. Google's sentence is about restarting the read from the log and does not mention a destination.
As of: 2026-10-09, against the Debezium 3.7 documentation (the exactly-once page exists for Debezium 3.3 to 3.7 and is absent from 3.2) and the Apache Kafka 4.1 documentation. These pages change; if a setting below does not match your version, trust your version's documentation.
Four hops, and what each guarantee means
| Hop | Default | What can change it | What you still do |
|---|---|---|---|
| 1. Database to connector (reading the log) | At-least-once. Debezium's page says no changes are missed but a record may be delivered more than once, and its FAQ explains why: after a crash the connector restarts from the last position it managed to record. | Nothing in Debezium. The page states that Debezium has no internal deduplication layer. | Carry the source log position on every event (event envelope) so a replay can be recognised later. |
| 2. Connector to Kafka topic | At-least-once. Without the opt-in, Kafka Connect writes the records and then, separately, the source offsets, so an interruption between the two repeats the records on restart (KIP-618). | The opt-in Kafka Connect exactly-once source mode, next section. Documented by Debezium from 3.3, with caveats. | Read the topic with isolation.level=read_committed if you opt in, and still treat the topic as possibly duplicated. |
| 3. Topic to sink (the consumer side) |
At-least-once. Kafka delivers at-least-once unless the writes and the offsets commit together.
Kafka Connect leaves sink exactly-once to the connector (it supplies hooks and the
read_committed setting), so whether a sink provides it depends on the connector. Debezium's JDBC sink documents at-least-once delivery.
|
Inside Kafka: transactions and Kafka Streams exactly_once_v2
(below). To an external system: only the sink's cooperation.
|
Make the write idempotent (sink section). |
| 4. Sink to its own store | Whatever the store's transaction gives you. A single database transaction is atomic. | Storing the consumed offsets in the same transaction as the rows, where the sink supports transactions. | Upsert on the primary key, apply only if the incoming source log position is newer, keep deletes as markers. |
Nothing in the table adds up to a single transaction from the database to the sink. The guarantee across independent systems is not achievable as delivery; the achievable thing is effectively-once processing: duplicates may arrive, and the sink makes them harmless.
Hop 2 in practice: the Kafka Connect opt-in
Kafka Connect supports exactly-once for source connectors from version 3.3.0, in distributed mode only (KIP-618, built on the transactions of KIP-98). Debezium's page documents it for the MariaDB, MongoDB, MySQL, Oracle, PostgreSQL and SQL Server connectors. The settings it lists:
# Every Connect worker (distributed mode, Connect 3.3.0 or later)
exactly.once.source.support=enabled
# The connector configuration, added to your existing Debezium settings.
# transaction.boundary is "poll" by default, and Debezium requires poll.
"exactly.once.support": "required",
"transaction.boundary": "poll"
What the framework does when you switch it on
-
Records and offsets commit together. With the default
pollboundary Connect starts a Kafka transaction for each batch a task returns frompoll, writes the batch and its source offsets inside it, and commits. After a crash either both are there or neither is, so a restarted task resumes from an offset that matches what is in the topic. -
Zombie tasks are fenced. Each task gets a transactional ID of the form
<group.id>-<connector>-<task id>. A new producer with that ID bars the older one from writing, which stops a task that has been replaced but is still running. -
The check happens before you start. With
exactly.once.support=requiredthe framework asks the connector whether it can provide exactly-once with that configuration. If it cannot, or the worker validating the request does not have exactly-once enabled, the request to create or validate the connector fails instead of silently running at-least-once. -
Rolling out is two steps on an existing cluster. Set
exactly.once.source.support=preparingon every worker first, thenenabled. The workers and the connectors also need ACLs for the transactional IDs and the offsets topic, which Debezium's page leaves to the Kafka documentation.
The framework can only do this for a connector that provides meaningful source offsets for each record and can resume from exactly the position an offset names, which is why the Kafka documentation says exactly-once source support “is highly dependent on the type of connector you run”.
What it does not do
- It covers the connector-to-topic hop. It does nothing for the sink, and the topic you read may still contain duplicates produced by other paths (an upstream replay, a re-snapshot you triggered, a second connector).
- A consumer on the default
isolation.level=read_uncommittedreturns records from aborted transactions too. A Kafka Connect sink must setconsumer.isolation.level=read_committedto benefit. - A
read_committedconsumer returns messages only up to the last stable offset, so it sees a batch only after that batch's transaction has committed.
The caveats Debezium itself lists
Debezium's page has a “Known issues and considerations” section. In short: it says it is unclear whether the implementation is fully correct, that no comprehensive study has analysed the correctness of transactions in recent Kafka releases, and that two Jepsen reports (on Redpanda and on Bufstream) raise concerns about the Kafka protocol and its implementations, particularly for Bufstream. It lists three Apache Kafka issues as open and reasons that, since Connect relies on Kafka transactions, they may affect Connect too. Their status in Apache JIRA on 2026-10-09:
| Issue | Title in JIRA | Status |
|---|---|---|
| KAFKA-17754 | A delayed EndTxn message can cause aborted read, lost writes, atomicity violation | Resolved (Fixed) on 2026-08-12. A committer's comment there says it “should be resolved by KIP-890 and its related followups”; no fix version is recorded, so check your broker release and your client versions, including the producer inside Connect (KIP-890 says older clients cannot get the epoch-bump defense). |
| KAFKA-17734 | KafkaConsumer.close(0) can block indefinitely in ConsumerNetworkClient.poll | Open |
| KAFKA-17582 | Unpredictable consumer position after transaction abort | Open |
The Debezium page lists all three as open, so the page and JIRA disagree on the first. This page does not know whether the two open ones affect a Debezium connector in practice, and neither does Debezium's page: it says it is reasonable to assume they might. That is why the opt-in is a reason to test, not a reason to drop the idempotent sink. KIP-890 describes the delayed-message problem and the server-side defense (a new epoch for each transaction).
Inside Kafka: transactions, offsets and read_committed
If a step reads a topic and writes another topic (a filter, an enrichment, a join), Kafka can make that step exactly-once, and Kafka's design documentation is explicit about where it stops. This hop sits on the same Kafka transactions as the Connect opt-in, so Debezium's reasoning about the open issues above applies to it too.
-
The idempotent producer is not enough on its own.
enable.idempotencedefaults totruein current Kafka and removes duplicates from producer retries, but KIP-98 notes that the guarantee holds within a single producer session: a restarted producer gets a new producer ID. -
A transactional producer (a Kafka transaction) sets
transactional.id, which also makes a restarted instance abort its predecessor's in-flight transaction. It writes the output records and callssendOffsetsToTransactionso the consumer's offsets commit atomically with them (KIP-447 lets the producer pass the consumer's group metadata). The consumer needsisolation.level=read_committedandenable.auto.commit=false. -
Kafka Streams does this for you:
processing.guarantee=exactly_once_v2(the default isat_least_once; v2 needs brokers 2.5 or newer). The documentation scopes it to the input offsets, the state stores and the output topics, committed atomically.
The scope is the sentence to remember: the Kafka documentation says exactly-once delivery “for other destination systems generally requires cooperation with such systems”. A processor that also writes to a database, a warehouse load or an HTTP endpoint is outside the transaction.
Hop 3 and 4: the sink, where it ends
Kafka Connect's documentation says it can provide exactly-once for sink connectors, but it depends on the
connector, and the one framework-level instruction is the consumer isolation level. Debezium's JDBC sink
says it guarantees events are processed at least once, tells you that after a restart or rebalance it can
receive the same event again, and tells you to set insert.mode=upsert to make the writes
idempotent. Its delete.enabled setting is off by default, so out of the box a delete event does
not remove the row. The page describes upsert as an update-or-insert on the primary key; it does not describe
comparing a source log position, so read your sink's documentation for what happens when an older change
arrives after a newer one.
Offsets in the sink's transaction
Kafka's own documentation describes the pattern for a destination that has transactions
(offsets in the sink transaction): let the consumer
store its offset in the same place as its output, because a two-phase commit between the offset store and the
output is usually not available. For a relational database, the KafkaConsumer documentation says
this lets you commit the result and the offset in a single transaction, and it lists the steps: set
enable.auto.commit=false, save the position with each write, and on restart or assignment
seek to it. The Kafka design documentation gives a Connect connector that writes data and offsets to
HDFS together as its example.
Here is that pattern with a position guard added. The statements use PostgreSQL syntax
(INSERT ... ON CONFLICT DO UPDATE ... WHERE);
the table and column names are examples. One transaction per Kafka record, or per batch:
CREATE TABLE customers (
id bigint PRIMARY KEY,
name text,
deleted boolean NOT NULL DEFAULT false, -- a delete is a marker, not a DELETE
src_lsn bigint NOT NULL -- source log position of the last applied change
);
CREATE TABLE sink_offsets (
topic text, part int, next_offset bigint NOT NULL,
PRIMARY KEY (topic, part)
);
BEGIN;
INSERT INTO customers (id, name, deleted, src_lsn)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name, deleted = EXCLUDED.deleted, src_lsn = EXCLUDED.src_lsn
WHERE customers.src_lsn < EXCLUDED.src_lsn; -- older or equal position: no-op
INSERT INTO sink_offsets (topic, part, next_offset)
VALUES ($5, $6, $7) -- the record's offset + 1
ON CONFLICT (topic, part) DO UPDATE SET next_offset = EXCLUDED.next_offset;
COMMIT;
On start or partition assignment, read next_offset from sink_offsets and
seek there. Run on one customer key, the same two statements behave like this (this repository's
tests run the statements as printed (the placeholder numbers differ and comments and the final COMMIT are stripped) on PGlite 0.5.8, which is PostgreSQL 18.3 compiled to WebAssembly,
and the logic in SQLite 3.54, which accepts the same ON CONFLICT ... WHERE form; the table below
matched in both. They were not run on a PostgreSQL server by this repository):
| Kafka offset | Change (source position) | Row after | Which guard acted |
|---|---|---|---|
| 40 | name = a, position 100 | a, 100 | None needed: first write. |
| 41 | name = b, position 110 | b, 110 | None needed: newer position. |
| 57 | name = b, position 110 (the source re-emitted the same change after a crash) | b, 110 (0 rows changed) | Position guard. The offset is new, so the offset table cannot recognise it. |
| 58 | name = a, position 100, arriving late | b, 110 (0 rows changed) | Position guard. |
The strict < makes a replay of the same change (same position) a no-op. If two different
changes to one key can share a position, compare the (position, seq) pair instead; the
merge cookbook shows that form.
The two guards cover different failures. Storing the offset with the row means a consumer that crashes
between the write and the offset commit cannot leave them out of step: the transaction rolled back or it
did not. It does not help with a duplicate that is already in the topic at a different offset, because that
is a different Kafka record. The position guard is what turns that into a no-op, and it also covers
backfills and replays that rewind the consumer. That is why the idempotent, position-ordered upsert stays
required even where the first hop is exactly-once. For deletes, keep the row with deleted = true so an
older change cannot bring it back; the merge cookbook and
materialization pages show the same guard in warehouse
MERGE form.
What guarantee do I really have?
Answer these in order; each one says what you can claim.
-
Is there a step that reads a topic and writes a topic, using a transactional producer or Kafka Streams
with
exactly_once_v2? Then that step is exactly-once with respect to its input and output topics, as long as every consumer of the output readsread_committed, and subject to the open Kafka transaction issues listed above. Claim that, and nothing past the topic. If you have no such step (the connector writes a topic and a plain consumer reads it), there is nothing exactly-once to claim between the topic and the consumer; go to 2. - Is the destination an external system (database, warehouse, search index, HTTP call)? Yes: you do not have exactly-once delivery. Go to 3. No, the destination is another Kafka topic: you have whatever step 1 gave you and the connector hop in step 4; skip to 4.
- Can the destination hold your consumer offsets in the same transaction as the data? If yes, a consumer crash cannot double-apply or skip a record. If no, a crash between the write and the offset commit repeats a record, and only the guard in step 5 protects you.
- Is the connector-to-topic opt-in off, or on? Off: the topic can contain duplicates after a connector crash. On: fewer, with the caveats above; still assume some.
- Is the write an upsert on the primary key, applied only if the incoming source log position is newer, with deletes kept as markers? If yes, you have exactly-once processing for this sink: any duplicate or replay leaves the same table. If no, you have whatever the sink's default is, and an older change can overwrite a newer one.
Step 5 is the one that holds regardless of the answers to 3 and 4, which is why the rest of this site treats at-least-once as the default and the idempotent sink as the guarantee.
Verify what your pipeline gives
A configuration that says “required” does not prove behaviour. Three checks, in the order of
cost. None of these ran against a live Debezium and Kafka stack for this page; the commands are written from
the Kafka tool documentation, and the jq pipeline was run on a small hand-made sample only.
-
Look for duplicates in the topic. Duplicates from the source show up as the same key and the
same source position at two different offsets. With the JSON converter and schemas enabled the path is
.payload, and.payload.source.lsnis PostgreSQL's position field; substitute your connector's (binlog file and position for MySQL,commit_lsnfor SQL Server,scnfor Oracle) and adjust the path for your converter. The console consumer prints a tombstone as the literal textnull, which the filter skips.
Every line printed is a change that appears more than once. Run it again withkafka-console-consumer.sh --bootstrap-server localhost:9092 \ --topic dbserver1.public.customers --from-beginning --timeout-ms 10000 \ --isolation-level read_committed \ | jq -R -r 'fromjson? | select(.payload.source.lsn != null) | [.payload.source.lsn, .payload.op, (.payload.after.id // .payload.before.id)] | @tsv' \ | sort | uniq -d--isolation-level read_uncommittedto see what a default consumer sees: with the opt-in on, the difference is records from aborted transactions. -
Inject the duplicates yourself. Stop the sink connector, rewind its consumer group, start it
and compare the table with a copy taken before. The Kafka documentation describes
kafka-consumer-groups.sh --reset-offsets --to-earliest --execute(the consumer instances must be inactive). If your sink stores offsets itself, as in the pattern above, also reset or delete itssink_offsetsrow, otherwise the consumer seeks to the stored offset and nothing is replayed. If the table changes, the sink is not idempotent. This is the cheapest test of the claim that matters. -
Kill the connector mid-batch. Send
SIGKILLto the Connect worker while changes are flowing, restart it, and run check 1. With the opt-in off you expect duplicates if the kill landed between writing records and recording offsets; with it on you would expect none in the typical case, but Debezium itself does not claim that holds, so a duplicate here is a finding, not proof the sink may be non-idempotent. In the opt-in-off run, fail the test if you injected no duplicates, since then the sink was never exercised. The Kafka + Debezium lab gives you a stack to try it on, and the offsets and replays page covers rewinds.
See the failure and the fix
The interactive demo on the Exactly-Once page steps through a consumer that writes to the sink, crashes before committing its offset, and re-processes the message, then shows the idempotent version. It demonstrates the sink side of hop 3; it does not simulate the Kafka Connect opt-in.
To see the same sink-side idea on a change feed, open Replay against a guarded sink in the playground: the log sends two changes a second time, and a position guard skips them. It models one lane and one key and does not make delivery exactly-once. The playground is a separate tool that contacts a third-party database (see the privacy note).
Exactly-Once, Hop by Hop: Knowledge Check
Test whether you can scope an exactly-once claim and name what each hop needs.
A vendor page says its CDC service delivers 'with exactly-once semantics'. What is the first thing to ask?
A CDC pipeline has several hops (database to connector, connector to topic, topic to sink, sink to its own store) and a guarantee on one hop says nothing about the next. The claim is only useful once it names the hop and the mechanism, and for the last hop that mechanism is usually an idempotent write.
Review the correct answer and explanation.
What does Debezium's exactly-once delivery page document, as of Debezium 3.7?
The page opens by saying Debezium provides at-least-once delivery and has no internal deduplication layer. It then documents that Kafka Connect's exactly-once support for source connectors (KIP-618) can be switched on for the hop into Kafka, and it warns that it is unclear whether the implementation is fully correct.
Review the correct answer and explanation.
Which consumer setting makes a downstream consumer ignore records from aborted Kafka transactions?
The consumer default is isolation.level=read_uncommitted, which returns transactional messages even if the transaction aborted. read_committed returns only committed ones. The other settings are producer or connector settings and do not change what a consumer reads.
Review the correct answer and explanation.
A sink stores the Kafka offset in the same database transaction as each row. After a crash the source re-emits an already-delivered change as a new record at a later offset. With no position guard on the upsert, what happens?
Storing offsets with the data protects against the consumer replaying the same Kafka record. A duplicate produced upstream is a different record with a different offset, so only a guard on the source log position (the row is updated only if the incoming position is higher) turns it into a no-op.
Review the correct answer and explanation.
Which statement scopes Kafka Streams processing.guarantee=exactly_once_v2 correctly?
Kafka Streams commits input offsets, state-store updates and output-topic writes atomically inside Kafka. An external database or HTTP call that a processor touches is outside that transaction, so it needs its own idempotency.
Review the correct answer and explanation.
Further resources
- Exactly-Once Semantics: the summary table, the demo, idempotent consumers and the outbox.
- Transactional Outbox and Relay: the outbox hop in detail.
- Errata: corrections to wording about exactly-once and effectively-once.
- Event envelope and Offsets & replays: the position fields and rewinds.
- Glossary: Kafka transaction, fencing, offsets in the sink transaction, at-least-once, deduplication, upsert, idempotent write.
- Debezium: Exactly-once delivery: scope, prerequisites, connectors, known issues.
- Debezium FAQ: why consumers must expect duplicate events.
- Debezium JDBC sink: at-least-once delivery, idempotent writes, delete mode.
- Kafka Connect user guide: exactly-once support, worker settings, ACLs, sink isolation level.
- Kafka Connect connector development guide: exactly-once source connectors, transaction boundaries, preflight validation.
- Kafka design: message delivery semantics and using transactions.
- KafkaConsumer: storing offsets outside Kafka.
- KafkaProducer:
sendOffsetsToTransaction. - Kafka Streams core concepts and configuration: processing guarantees and
processing.guarantee. - Kafka basic operations: resetting consumer group offsets.
- KIP-98, KIP-447, KIP-618, KIP-890.
- PostgreSQL INSERT:
ON CONFLICT DO UPDATE ... WHERE.