The phrase covers three different things and only the third is what people usually mean. Getting the difference wrong is the commonest expensive mistake in this area.
Idempotent producer
enable.idempotence=true
Deduplicates retries within one producer session, per partition. The producer gets a producer id and a sequence number per partition, and the broker rejects a duplicate sequence. This removes duplicates caused by retries and nothing else. It does NOT survive a producer restart: a new session gets a new producer id.
What to do: It is the default since 3.0 and costs almost nothing, so leave it on. Note that it requires acks=all, max.in.flight.requests.per.connection at most 5, and retries above 0, and the producer refuses to start if you contradict any of them.
enable.idempotence=true
acks=all
max.in.flight.requests.per.connection=5
Transactions
transactional.id
Makes a set of writes across partitions atomic, and ties the consumer's offset commit into the same transaction. This is what makes consume-transform-produce exactly-once: either the output records and the offset commit both land, or neither does. It survives a restart, because the transactional.id lets a new instance fence the old one.
What to do: The transactional.id must be STABLE per logical producer instance across restarts. Generating a fresh one at startup, which is a very common mistake, gives none of the guarantee and leaks a producer id per restart into the transaction state topic.
transactional.id=payments-processor-${POD_ORDINAL}
producer.initTransactions();
producer.beginTransaction();
producer.send(...);
producer.sendOffsetsToTransaction(offsets, groupMetadata);
producer.commitTransaction();
The consumer half nobody sets
isolation.level=read_committed
The default is read_uncommitted, which means a consumer reads records from transactions that were later aborted. A producer using transactions while its consumer uses the default gets no benefit at all: the whole apparatus is running and the reader still sees aborted data.
What to do: Set isolation.level=read_committed on every consumer downstream of a transactional producer. This is the single most commonly missed setting in an exactly-once pipeline, and the pipeline appears to work without it.
# on EVERY consumer downstream of a transactional producer
isolation.level=read_committed
What it does not cover
Exactly-once is a property of the Kafka-to-Kafka path. The moment a side effect leaves Kafka, an email, an HTTP call, a write to a database without a shared transaction, the guarantee stops at the boundary. Kafka cannot roll back an email.
What to do: For an external side effect, make it idempotent rather than transactional. A de-duplication key derived from the record is more robust than any distributed transaction, and it works with systems that offer none.