Skip to main content
Kafka intermediate Lesson 6 of 10

At-Most-Once, At-Least-Once, and Exactly-Once

Reproduce message loss and duplication on purpose, then configure the producer and consumer to get each of Kafka's three delivery guarantees.

“Delivered” is not one thing. Kafka lets you choose which failures you would rather have, and the choice is made through configuration on both the producer and the consumer.

At-most-once: lose records, never duplicate them

The consumer commits its offset before doing the work. If it crashes mid-processing, the offset has already moved on and the record is never retried.

import time
from confluent_kafka import Consumer

consumer = Consumer({
    "bootstrap.servers": "localhost:9092",
    "group.id": "at-most-once",
    "auto.offset.reset": "earliest",
    "enable.auto.commit": False,
})
consumer.subscribe(["orders"])

while True:
    msg = consumer.poll(1.0)
    if msg is None or msg.error():
        continue

    consumer.commit(msg)                      # commit FIRST
    print(f"committed @{msg.offset()}, now processing")
    time.sleep(2)
    if msg.offset() == 2:
        raise RuntimeError("crash mid-processing")
    print(f"  processed @{msg.offset()}")
$ python at_most_once.py
committed @0, now processing
  processed @0
committed @1, now processing
  processed @1
committed @2, now processing
Traceback (most recent call last):
  ...
RuntimeError: crash mid-processing

$ python at_most_once.py
committed @3, now processing
  processed @3

Offset 2 was committed but never processed, and it is gone for this group. Choose this only when stale data is worse than missing data — a metrics stream where you would rather drop a sample than double-count it.

At-least-once: never lose, sometimes duplicate

Commit after the work. This is the default posture for almost every pipeline.

consumer = Consumer({
    "bootstrap.servers": "localhost:9092",
    "group.id": "at-least-once",
    "auto.offset.reset": "earliest",
    "enable.auto.commit": False,
})
consumer.subscribe(["orders"])

seen = []
while True:
    msg = consumer.poll(1.0)
    if msg is None or msg.error():
        continue

    print(f"processing @{msg.offset()}")
    seen.append(msg.offset())
    time.sleep(2)
    if msg.offset() == 2 and seen.count(2) == 1:
        raise RuntimeError("crash after processing, before commit")
    consumer.commit(msg)                      # commit LAST
    print(f"  committed @{msg.offset()}")
$ python at_least_once.py
processing @0
  committed @0
processing @1
  committed @1
processing @2
Traceback (most recent call last):
  ...
RuntimeError: crash after processing, before commit

$ python at_least_once.py
processing @2
  committed @2
processing @3
  committed @3

Offset 2 was processed twice. Nothing was lost, and that is the trade. Your processing has to tolerate it — write with INSERT … ON CONFLICT DO UPDATE, or key your writes by event ID so a repeat is a no-op.

Exactly-once: transactions

For a consume-process-produce loop that stays inside Kafka, a transaction makes the output writes and the offset commit atomic.

from confluent_kafka import Consumer, Producer, KafkaError

consumer = Consumer({
    "bootstrap.servers": "localhost:9092",
    "group.id": "eos-processor",
    "auto.offset.reset": "earliest",
    "enable.auto.commit": False,          # required — the transaction commits offsets
})
consumer.subscribe(["orders"])

producer = Producer({
    "bootstrap.servers": "localhost:9092",
    "transactional.id": "order-enricher-1",   # must be stable across restarts
    "enable.idempotence": True,
})
producer.init_transactions()

while True:
    msg = consumer.poll(1.0)
    if msg is None or msg.error():
        continue

    producer.begin_transaction()
    try:
        enriched = msg.value().decode().upper()
        producer.produce("orders-enriched", key=msg.key(), value=enriched)

        # The offset commit joins the transaction rather than going through the consumer.
        producer.send_offsets_to_transaction(
            consumer.position(consumer.assignment()),
            consumer.consumer_group_metadata(),
        )
        producer.commit_transaction()
        print(f"committed txn for @{msg.offset()} -> {enriched}")
    except Exception as e:
        producer.abort_transaction()
        print(f"aborted txn for @{msg.offset()}: {e}")
$ python eos.py
committed txn for @0 -> ESPRESSO
committed txn for @1 -> CORTADO
committed txn for @2 -> FLAT WHITE

If the process dies between produce and commit_transaction, the transaction is aborted by the broker and neither the output record nor the offset advance survive. On restart the input record is reprocessed and written exactly once.

The reader has to opt in

An aborted transaction still writes bytes to the log — the broker marks them aborted rather than removing them. A consumer only hides those records if you ask it to:

consumer = Consumer({
    "bootstrap.servers": "localhost:9092",
    "group.id": "downstream",
    "auto.offset.reset": "earliest",
    "isolation.level": "read_committed",     # default is read_uncommitted
})

Compare the two on a topic where one transaction was aborted:

bin/kafka-console-consumer.sh --topic orders-enriched --from-beginning \
  --consumer-property isolation.level=read_uncommitted \
  --max-messages 10 --bootstrap-server localhost:9092
ESPRESSO
CORTADO
ROLLED-BACK-RECORD
FLAT WHITE
Processed a total of 4 messages
bin/kafka-console-consumer.sh --topic orders-enriched --from-beginning \
  --consumer-property isolation.level=read_committed \
  --max-messages 10 --bootstrap-server localhost:9092
ESPRESSO
CORTADO
FLAT WHITE
Processed a total of 3 messages

Exactly-once on the write side is undone by a reader left on the default read_uncommitted. Both halves have to be configured.

Choosing

GuaranteeProducerConsumerCostUse when
At-most-onceacks=0 or 1commit before processingcheapestdropping beats duplicating
At-least-onceacks=all, idempotentcommit after processinglowprocessing is idempotent — most pipelines
Exactly-oncetransactional.idread_committed, offsets in txn~3-10% throughput, added latencyKafka-to-Kafka, non-idempotent processing

The honest default is at-least-once with idempotent downstream writes. Reach for transactions when the processing genuinely cannot be made idempotent and both ends of the pipeline are Kafka.

Practice

1. Set acks=0, stop the broker, and produce. What do the callbacks report?
ok  partition=0 offset=-1
ok  partition=0 offset=-1

Success, with offset -1. acks=0 means the producer never waits for a reply, so it reports delivery the moment the bytes hit the socket. Offset -1 is the tell: the broker never assigned one. This is genuine fire-and-forget, and the fastest way to lose data without noticing.

2. Run the transactional processor twice with the same transactional.id at the same time. What happens to the first one?
FATAL: Local: This instance has been fenced by a newer instance

init_transactions() from the second process bumps the producer epoch, fencing the first. This is deliberate — it guarantees only one live writer per transactional ID, so a half-dead process cannot keep writing. It also means the ID must be stable and unique per instance, not shared across replicas.

3. With read_committed, start a transaction, produce, and wait 30 seconds before committing. What does a downstream consumer see?

Nothing, for 30 seconds — then all the records at once. A read_committed consumer cannot deliver past an open transaction, because it does not yet know whether those records will commit or abort. Long transactions therefore stall every downstream reader for their whole duration, which is why transactions should wrap one batch, not one job.

4. Your consumer writes to Postgres and you need exactly-once end to end. Do Kafka transactions give you that?

No. The Kafka transaction covers Kafka writes and the offset commit; a Postgres write is outside it, so a crash between the two leaves them inconsistent. The workable patterns are an idempotent upsert keyed by (topic, partition, offset), or storing the offset in Postgres in the same database transaction as the data and seeking from there on startup — letting Postgres, not Kafka, be the source of truth for position.

Next: giving your records a schema, so producers and consumers can evolve independently.

Frequently Asked Questions

Is Kafka's exactly-once real, or marketing?
It is real but scoped. Kafka guarantees exactly-once for reads and writes that stay inside Kafka — consume from a topic, process, produce to another topic, and commit offsets in one transaction. It cannot make an external side effect like an email send or a third-party API call exactly-once, because those systems are not part of the transaction.
What is the difference between idempotence and transactions?
Idempotence stops a producer's own retries from writing a record twice to one partition. Transactions go further: they make writes across multiple partitions and the consumer offset commit atomic, so a consume-process-produce cycle either fully happens or fully does not.
Why is at-least-once the usual choice?
It is far cheaper than transactions and safe whenever your processing is idempotent — an upsert by primary key, a set insert, a counter keyed by event ID. Most pipelines can be made idempotent downstream more cheaply than they can run transactionally.
Does read_committed slow consumers down?
It adds latency rather than throughput cost. A read_committed consumer cannot deliver records past an open transaction, so it waits until that transaction commits or aborts. With short transactions the delay is milliseconds; with long ones consumers stall for the transaction's duration.