LogsGENERALBROKER-SPECIFICSIMPLIFIED

Offsets and Commits

Commit before processing and you get at-most-once. Commit after and you get at-least-once. There is no third option unless the commit and the output write share a transaction.

What actually happensHow to build itCan I trust it?

Who needs this, what one row is, and why the obvious build breaks

Every lesson starts from the consumer, because designing from the source outward is this domain's characteristic mistake.

The question

A consumer crashes between reading a record and writing its result. Was that record lost, or will it be processed twice?

Who needs this

Every sink downstream of a stream consumer — a warehouse table, an object-store partition, a state store, a downstream topic. What each of them needs to know is not "did the consumer succeed" but "if this record appears twice, does the sink still hold the right answer". That question, and not the broker's configuration, is what decides the pipeline's real delivery semantics (Idempotent Data Pipelines).

What one row is

The committed offset is a per-(group, topic, partition) integer meaning "everything before this position is done". It is a claim about a prefix of one partition, not about a record, which is why partial progress within a batch is not expressible and why batch boundaries decide how much gets reprocessed (Topics and Partitions).

The obvious build

Use the client's automatic commit: it advances the offset periodically in the background while your loop processes records. It is one configuration line, it requires no thought, and under normal operation it behaves exactly as intended.

Why it breaks

The consumer crashes after an automatic commit fired and before the records it covered were written. On restart the group resumes past them, and those records are never processed by anyone. The loss is silent, permanent, and invisible in every broker metric (Missing Rows).

How it breaks with real data
  • The consumer crashes after an automatic commit fired and before the records it covered were written. On restart the group resumes past them, and those records are never processed by anyone. The loss is silent, permanent, and invisible in every broker metric (Missing Rows).
  • The consumer crashes just before a commit. On restart it reprocesses the whole batch, and because the sink appends rather than upserts, every record in that batch now exists twice in the warehouse (Duplicate Rows).
  • A rebalance revokes a partition mid-batch. The new owner starts from the last committed offset, which is behind what the old owner had already written, so the overlap is reprocessed — a routine event on every deploy (Consumer Groups and the Parallelism Ceiling).
  • The commit interval is lengthened to reduce commit overhead, which quietly increases the amount of work reprocessed after any failure from seconds of records to minutes of records.
  • Someone reads that the broker supports transactions and concludes the pipeline is exactly-once. The transaction covers the offset commit and writes back into the same cluster; the warehouse the pipeline actually writes to is not part of it and never was (Exactly-Once: Input Consumption, State Update, Output Write).
SourceIngestionRawTransformationValidationStorage ModelServingConsumerObservability

What is actually happening

  • An offset is a position in a partition, and committing it stores that integer for the consumer group so a restart or a reassignment resumes from there. It is the entire state a consumer keeps — but it is a separate action from the processing, and the two cannot be made atomic by ordering alone. The only question is which one you do first, and that choice is the delivery semantic (The Event Log).
  • Commit first, then process gives at-most-once: a crash in between loses the records, because the group has already claimed to be past them. Nothing is ever duplicated and records can silently disappear.
  • Process first, then commit gives at-least-once: a crash in between reprocesses the records, because the group has not yet claimed to be past them. Nothing is ever lost and records can be duplicated.
  • There is no third ordering. The only way to remove the gap is to make the offset commit and the output write one atomic operation, which requires a sink that can participate in a transaction with the offset store — a narrow and specific condition, not a broker feature you enable (Transactions and ACID).
  • The practical alternative, and the one most data platforms actually use, is at-least-once delivery with an idempotent sink: write keyed on a stable business identifier so a redelivered record overwrites itself rather than adding a row. The effect is once; the delivery is not (Upserts and Merges).
  • Automatic commit is process-first ordering with an unpredictable boundary: the commit fires on a timer, not at a point you chose, so the amount of work at risk varies and the ordering guarantee is weaker than it appears.

One integer, and the gap it cannot close

A consumer's entire durable state is a committed offset per partition, meaning "everything before this position is done". Recovery is therefore trivial: read the integer, resume. That simplicity is the log's great operational gift and it comes with one unavoidable consequence.

The consequence is that "done" is a claim the consumer makes, and it makes it at a moment of its own choosing — before doing the work, or after. Between the read and the commit there is a window, and a crash in that window has a different outcome depending on which side of it the commit sits. There is no way to shrink the window to nothing by ordering alone, because two writes to two systems cannot be made atomic by putting them next to each other.

The loop below is the shape every consumer has, with the three placements marked. Two of them are real choices with known semantics. The third — the default in most clients — is a timer that fires wherever it happens to fire, giving you process-first ordering with a boundary nobody chose.

The same loop, three commit placements
1# The shape common to log-client libraries: poll for records, do work,
2# commit a position. No library-specific API is assumed beyond that.
3
4# (A) AT-MOST-ONCE — commit first.
5for batch in consumer.poll():
6 consumer.commit() # claim "done" before it is true
7 write_to_warehouse(batch) # crash here: these records are gone
8
9# (B) AT-LEAST-ONCE — commit after a DURABLE write.
10for batch in consumer.poll():
11 write_to_warehouse(batch) # must be durably acknowledged, not buffered
12 consumer.commit() # crash before this: batch is reprocessed
13
14# (C) EFFECTIVELY-ONCE EFFECTS — (B) plus an idempotent sink.
15for batch in consumer.poll():
16 upsert_to_warehouse(batch, key="event_id") # re-writing is harmless
17 consumer.commit()
18
19# There is no (D) that removes the gap by reordering. Removing it requires
20# the offset commit and the write to be ONE transaction, which requires a
21# sink that can participate in it.

The difference between (B) and (C) is not in this file — it is in write versus upsert, and in whether event_id is stable across a redelivery. That is why delivery semantics are a property of the sink and the identifier, not of the consumer loop or of the broker.

partition 3

  offsets:   ... 4808  4809  4810  4811  4812  4813  4814 ...
                             ^                       ^
                             committed = 4810        position = 4814
                             |                       |
                             +--- the at-risk window -+
                             records 4810..4813: read, maybe processed,
                             not yet claimed as done

  crash here, commit-AFTER-process  ->  4810..4813 read again
                                        (at-least-once; duplicates)
  crash here, commit-BEFORE-process ->  4810..4813 never processed
                                        (at-most-once; silent loss)

  The window cannot be made zero by ordering.
  It can only be made ATOMIC, and only if the sink can join
  the same transaction as the offset store.

Two orderings, two failure shapes

The choice reduces to which failure you would rather have. At-most-once never duplicates and can lose; at-least-once never loses and can duplicate. For a data platform this is not a close call: a duplicate is a row you can find and remove, and a loss is a row that never existed and that only a reconciliation against the source will ever reveal.

What makes at-least-once safe is not the ordering — it is what the sink does with a record it has already seen. An upsert keyed on a stable identifier absorbs the redelivery and leaves the table correct. An append does not, and an append is the default behaviour of most file-based and warehouse-loading sinks.

So the real design instruction is: choose at-least-once, then spend the effort on the identifier and the write. The failure table below is a list of the ways that effort gets skipped, each of which produces a pipeline that is green while being wrong.

How the commit boundary goes wrong
TriggerSymptomCauseResponse
Automatic commit plus an asynchronous or buffered write.Occasional missing rows after crashes and deploys. Counts are slightly short in a way nobody can attribute.The offset advanced on a timer while the write was still in a buffer. Effectively at-most-once.Disable automatic commit; commit only after the destination acknowledges durably. Add reconciliation against the source, because uniqueness tests cannot see loss (Reconciliation).
At-least-once delivery into an append-only sink.Revenue and counts drift upward. Growth looks strong. Duplicates cluster around deploy times.Every crash, deploy and rebalance reprocesses the uncommitted batch, and the sink adds rather than replaces.Switch the write to an upsert on a stable business key, or publish by atomic partition swap. Add a uniqueness test on that key so a regression is loud (Upserts and Merges, Duplicate Rows).
A group id derived from a hostname or a random value.Each restart either reprocesses the entire topic or starts at the head, depending on the reset policy.A new group has no committed offsets, so the reset policy decides — and both of its options are wrong for a running pipeline.Derive the group id from the logical purpose and pin it in configuration. Treat a group id change as a data operation requiring a plan (Consumer Groups and the Parallelism Ceiling).
A "transactional" consumer writing to an external warehouse.Duplicates appear despite the transaction being correctly configured and working.The transaction binds the offset commit to writes within the broker's own cluster. An external sink is not a participant and never was.Name the boundary the guarantee applies to. For the external write, use an idempotent upsert — that is what actually produces once-effects there (Exactly-Once: Input Consumption, State Update, Output Write).
Deduplication with a window shorter than the real redelivery gap.Duplicates pass through after long outages, exactly when volumes are already anomalous.Dedup state was sized for the common case; the reprocess after a multi-hour outage exceeds it.Prefer sink-side idempotency, whose window is the lifetime of the table rather than the lifetime of a state store (Deduplication, Streaming State).
Where the commit goes
Automatic commit on a timer
Leave the client's automatic commit enabled. Offsets advance in the background at a fixed interval while the loop processes records, and the write to the destination is asynchronous or buffered.
Explicit commit after a durable write, into an idempotent sink
Disable automatic commit. Write the batch, wait for the destination to durably acknowledge it, then commit the offset. Key the write on a stable business identifier so a redelivered batch overwrites itself instead of adding rows.

The automatic commit fires on a timer with no knowledge of whether the corresponding write has landed, so it can advance past records whose effects never happened — at-most-once behaviour in a configuration nobody chose it for, producing losses that no broker metric and no uniqueness test can see. Explicit commit after a durable acknowledgement makes the ordering a decision, and the idempotent write makes the resulting duplicates harmless. The pair is what turns at-least-once from a caveat into a design.

Scoping "exactly-once": consumption, state, output

The phrase is not meaningless, but it is only meaningful once it is split into three separate questions: was the input consumed once, was the internal state updated once, and was the output written once? Different mechanisms answer different ones, and no single setting answers all three across systems you do not control.

Input consumption can be made once-effective by binding the offset commit to whatever records progress. State update can be made once-effective by checkpointing state and offsets together. Output write can be made once-effective only if the destination participates in that same transaction, or if the write is idempotent so that repeating it changes nothing.

The stages below name what each promises and how each fails. The honest summary for most data platforms is the last row: at-least-once input, checkpointed state, idempotent output, which produces once-effects at the sink without any claim of exactly-once delivery. That is a precise, achievable, defensible guarantee, and it is what to write in the dataset's contract instead of the phrase (Data Contracts).

The three stages the phrase has to be split into
  1. 1
    Input consumption

    Reads records from the partition and advances a committed offset.

    guarantees At-least-once by default: after a failure, everything since the last commit is read again. Once-effective only if the commit is atomic with whatever records progress.

    fails by An automatic commit advancing past records whose effects never landed, which converts it to at-most-once silently.

  2. 2
    State update

    Maintains aggregates, joins or deduplication state across records.

    guarantees Consistent with a position only if state and offset are checkpointed together as one unit. Otherwise the two can disagree after a crash or a rebalance.

    fails by A rebalance moving a partition to an instance with empty in-memory state, producing one wrong window rather than an error (Checkpointing, Streaming State).

  3. 3
    Output write — transactional sink

    Writes results into a destination that can join the same transaction as the offset commit.

    guarantees Exactly-once for this pair specifically: the commit and the write succeed together or not at all.

    fails by Being assumed to extend to sinks outside the transaction. It covers what it covers and nothing else.

  4. 4
    Output write — idempotent sink

    Upserts on a stable business key, or publishes a rebuilt partition by atomic swap.

    guarantees Once-effects at the destination under any number of redeliveries. Delivery remains at-least-once and that is fine.

    fails by An unstable identifier — a producer-generated id that changes on retry — which makes two records look genuinely distinct (Upserts and Merges).

  5. 5
    Output write — append-only sink

    Appends rows or files without any key.

    guarantees Nothing beyond durability. Every redelivery is a new row.

    fails by Working perfectly until the first crash, deploy or rebalance, then inflating every measure while every job stays green (Duplicate Rows).

  6. 6
    The achievable combination

    At-least-once input, checkpointed state, idempotent output.

    guarantees Once-effects at the sink, no loss, and a bounded amount of reprocessing after any failure. State a dataset contract with this, not with the phrase "exactly-once".

    fails by Requiring a stable identifier from the source. If the source cannot provide one, this whole structure has nothing to hang on (Deduplication).

Read the guarantees column as three separate promises rather than one. Every honest use of "exactly-once" names which of the three it means and what assumption buys it; every dishonest one omits both.

Which delivery semantics for this consumer?

What can the destination do about a record it has already seen?

At-least-once with an idempotent sink

when The destination can upsert on a stable key, or you can publish a rebuilt partition atomically. The default answer for a data platform.

cost A more expensive write on every record, and a dependency on the source providing an identifier that survives redelivery.

At-least-once with downstream deduplication

when The sink cannot upsert, so duplicates are removed in a later model instead.

cost Deduplication state proportional to the key space and window, and a window that is wrong for exactly the long-outage case where duplicates actually appear (Deduplication).

Transactional sink

when The destination genuinely participates in a transaction with the offset store — most commonly a stream-to-stream stage inside one cluster.

cost Latency and throughput on every batch, and a guarantee that stops precisely where the transaction stops, which must be documented or it will be over-claimed.

At-most-once

when A lost record is genuinely preferable to a duplicate — some telemetry and best-effort metrics. Rare, and it should be an explicit decision.

cost Silent, undetectable loss unless you reconcile against the source. Never a good default for data anyone will make a decision from.

How to build it

Most important first.

  • Choose at-least-once and make the sink idempotent: for a data platform this is almost always right, because reprocessing a record is recoverable and losing one is not. Turn automatic commit off and commit explicitly after the write has been durably acknowledged, so the boundary is one you chose and can reason about (Checkpointing).
  • Give every record a stable identity that survives redelivery — an event id, or source table plus primary key plus log position — and key the sink write on it. Without that, idempotency is not implementable and at-least-once becomes duplication (Deduplication).
  • Prefer an upsert or an atomic partition swap over an append. Appending is what turns redelivery into duplicate rows, and appending is the default in most file-based sinks (Atomic Publish).
  • Size the batch between commits deliberately. It is the amount of work reprocessed after any failure, and it trades commit overhead against redo cost — a real knob with a real cost on both ends.
  • Never claim exactly-once without naming which of input consumption, state update and output write it covers, and what assumption buys it. The claim is meaningless without that scoping and misleads every consumer who hears it (Exactly-Once: Input Consumption, State Update, Output Write).
  • Handle the poison record explicitly: catch, publish to a quarantine topic, commit past. Without it a single unparseable record stops the partition permanently (A Dead-Letter Queue Is a Workflow, Not a Bin).

What this actually promises

Naming the guarantee you do not have is worth more than naming the one you do — everything downstream inherits the weakest promise in the chain.

  • Commit-before-process guarantees no duplicates and does not guarantee no loss. This is at-most-once and is almost never the right choice for data.
  • Commit-after-process guarantees no loss and does not guarantee no duplicates. This is at-least-once and is the correct default for a data platform.
  • An idempotent sink keyed on a stable identifier gives effectively-once effects on the output, while delivery remains at-least-once. The guarantee lives in the sink, not in the broker.
  • A transaction spanning the offset commit and the output write gives exactly-once for that specific pair, and only when the sink can join that transaction. It says nothing about any other sink the pipeline writes to.
  • No configuration gives end-to-end exactly-once delivery across a broker and an external warehouse or object store. Anyone who says otherwise has not said which of the three stages they mean (Exactly-Once: Input Consumption, State Update, Output Write).
  • A committed offset guarantees that a consumer moved past a record. It guarantees nothing whatsoever about whether the record was processed correctly, or at all (The Pipeline Succeeded. The Data Is Wrong.).

Can I trust it?

A green pipeline is evidence that code ran. These four fields are the evidence that the data is right.

The check that would catch this
  • Assert uniqueness on the business key in the sink. It is the check that makes at-least-once safe to rely on, because it turns a silent duplication into a failed test (Data Tests).
  • It misses redeliveries that arrive with a fresh event id — two genuinely distinct records describing one occurrence — which is why the identifier must come from the source, not from the producer's retry path.
  • Reconcile counts for a closed period against the source. Uniqueness catches duplicates and reconciliation catches loss, and at-most-once semantics produce loss that no uniqueness test will ever see (Reconciliation).
Freshness
  • Commit frequency trades freshness of *recovery* against overhead: frequent commits mean less redo after a failure and more commit traffic; infrequent commits mean the opposite. Neither affects how quickly a record is processed in the healthy case.
  • Batching for throughput increases the delay between reading a record and its effects being visible downstream, and increases the redo window by the same amount. It is one knob with two consequences.
  • A transactional sink adds commit latency to every batch, because visibility waits for the transaction. That is the freshness price of the narrow exactly-once guarantee, and it should be stated when the guarantee is claimed (Cost vs Freshness).
When the schema or meaning changes
  • Changing the group id creates a new group with no committed offsets, which starts from the configured reset position: a full reprocess, or a silent gap. A rename in a deployment manifest is therefore a data operation (Consumer Groups and the Parallelism Ceiling).
  • Changing the sink from an append to an upsert changes the pipeline's effective delivery semantics without changing a single broker setting, and is one of the most valuable changes available here.
  • Changing the identifier used for idempotency invalidates deduplication against everything already written, so the old and new keying overlap for one window and produce duplicates that look like a delivery problem (Schema Evolution).
How to re-run this safely
  • Recovery from an at-least-once pipeline is a re-run: reset the offset, reprocess, and let the idempotent sink absorb the overlap. This works only because the sink was built for it (Replay from the Log).
  • Recovery from an at-most-once pipeline that lost records requires reprocessing the affected offset range from the log — possible within retention, impossible outside it, and you have to know which range was lost (Retention and Replay).
  • To reprocess without disturbing production, run a second group into a scratch destination, validate, and swap. Groups are independent, so this costs read bandwidth and nothing else (Planning a Backfill).
  • Rewinding an offset is trivial and rewinding a *sink* is not. Plan recovery around what the destination can absorb, because that is the constraint, not the log (Upserts and Merges).

What can go wrong

Failure modes
  • Automatic commit combined with a slow or asynchronous write, so offsets advance past records whose effects never landed. The most common silent-loss configuration in the ecosystem, and it is the default.
  • An append-only sink under at-least-once delivery, accumulating duplicates on every crash, deploy and rebalance — a slow inflation that looks like business growth.
  • A commit that happens after a write to an in-memory buffer rather than after a durable acknowledgement, which is at-most-once wearing the shape of at-least-once.
  • A "transactional" pipeline whose transaction covers the offset store and one topic, while the actual destination is a warehouse outside it — a correctly implemented guarantee about the wrong boundary.
  • A deduplication window shorter than the real redelivery gap, so duplicates separated by a long outage pass straight through (Deduplication).
  • A group id derived from a pod name or a random value, creating a new group on every restart and reprocessing or skipping the entire topic each time.
Misreads
  • "Kafka gives exactly-once." Not without naming which write it covers. Kafka can make an offset commit atomic with a write back into Kafka, which covers input consumption and one kind of output write within one cluster. Your warehouse is not in that transaction (Exactly-Once: Input Consumption, State Update, Output Write).
  • "At-least-once means we will see duplicates, which is a bug." Duplicates are the designed behaviour, and the fix is an idempotent sink rather than a broker setting. A pipeline whose correctness depends on never seeing a duplicate is already broken (At-Least-Once Delivery).
  • "Automatic commit is fine, it commits after processing." It commits on a timer. Whether the records it covers were durably written depends on your write path, and with an asynchronous write the answer is often no.
  • "Committing the offset means the record was processed." It means the consumer moved past it. Successful processing is a separate fact that only the sink can attest to (The Pipeline Succeeded. The Data Is Wrong.).
  • "We deduplicate downstream, so delivery semantics do not matter." They decide whether you have duplicates you can remove or losses you cannot detect. Deduplication only helps with one of the two.
  • "Rewinding the offset undoes the run." It re-reads the input. Whatever the previous run wrote is still in the sink, and unless the sink is idempotent the rewind adds to it rather than replacing it.

Operating it

How you see it in production
  • Committed offset versus processed offset per partition. A persistent gap between them tells you the ordering is not what you believe, and it is not a number any default dashboard shows.
  • Duplicate rate in the sink: rows written versus distinct business keys, per period. This is where at-least-once becomes visible as data (Duplicate Rows).
  • Reconciliation gap against the source for a closed period, which is where at-most-once becomes visible. Duplication and loss need different detectors and most platforms have only one (Reconciliation).
  • Rebalance and restart counts per group, since each is a reprocess window and their frequency sets how often idempotency is actually exercised.
  • Commit failure rate, which is a strong leading indicator of both duplication (commits not landing) and stalled progress (Six Queue Signals, Two That Wake You Up).
What changes at 10x and 100x
  • At 10x, larger batches become attractive for throughput and quietly enlarge the redo window after each failure by the same factor.
  • At 100x, deduplication state becomes a real system with its own memory and compaction concerns, and the cheap answer — an upsert into a keyed sink — starts to look much better than an in-pipeline dedup (Deduplication).
  • More partitions mean more independent commit streams, so commit overhead scales with partitions rather than with records and a topic with an extravagant partition count pays for it here too.
  • Rebalance frequency scales with instance count and deploy frequency, so at scale the reprocess-on-rebalance path is exercised constantly rather than occasionally — which is an argument for idempotency, not against scaling.
What drives cost here
  • Commit frequency costs commit traffic; commit rarity costs redo work after every failure. Both are small in the healthy case and the redo cost is what dominates during an incident, which is when it matters.
  • Idempotent writes cost more than appends — an upsert reads and merges where an append only writes — and that cost is paid on every record forever to make a rare event safe. It is worth it and it should be understood as insurance (Upserts and Merges).
  • Deduplication state costs storage and memory proportional to the key space and the window length. A long window is expensive and a short one is unsafe (Streaming State).
  • A transactional sink costs latency and throughput on every batch to buy a guarantee that applies only where the transaction reaches.
What this approach costs
  • At-least-once with an idempotent sink buys no data loss and costs a more expensive write on every record plus a stable identifier you must obtain from the source.
  • A transactional sink buys a genuinely exactly-once boundary and costs latency, throughput, and a hard constraint on which sinks are permitted.
  • At-most-once buys nothing a data platform wants. Its only honest use is a stream where a lost record is genuinely preferable to a duplicated one, which is rare outside metrics and telemetry.

Where this applies

Almost nothing here is universal. These labels say what each claim is specific to, and where a different engine, format, warehouse or scale would differ.

  • GENERALThe two orderings and their consequences are a property of any system where consuming and acknowledging are separate operations, and appear identically in queues, in webhook receivers and in file-based ingestion with a marker. What varies is where the position is stored and how large the at-risk batch is.
  • BROKER-SPECIFICKafka stores committed offsets broker-side per group and can bind an offset commit to a produce into the same cluster in one transaction. Kinesis leaves checkpointing to the client library and an external table, so the equivalent atomicity must be built. Pub/Sub acknowledges per message rather than by prefix, which removes the batch-boundary problem and replaces it with per-message acknowledgement deadlines.
  • SIMPLIFIEDThe loop below is written as a single-partition sequential consumer. Real consumers handle several partitions with independent positions, and committing a batch that spans partitions commits a prefix per partition — the failure reasoning is per-partition, and the single-partition framing is a teaching simplification that hides nothing essential.

Where the depth lives

This domain teaches how data moves and how you know it arrived intact. It hands the rest off by name.

Domains that do not exist yet
  • Distributed Systems owns the delivery-semantics taxonomy itself and the reason at-most-once and at-least-once are the only two options across an unreliable channel — plus what a distributed transaction would cost if you wanted the third.
  • Distributed Systems also owns why an acknowledgement whose response was lost is genuinely ambiguous to the sender, which is the root cause of every duplicate in this lesson.