Kafka Offset
In Apache Kafka, an offset is a unique number that identifies the position of a message within a partition. Think of it like a page number in a book: each message in a partition gets a sequential number, starting from 0.
This guide explains what offsets are, how consumers commit them, where Kafka stores them, how automatic and manual commits affect message loss and duplicates, and how to rewind, reset and monitor offsets, with formatted Java, command-line and Spring Boot examples. It builds on Kafka Partition and Kafka Consumer.
1. What Is a Kafka Offset?
Every time a producer writes a message to a partition, Kafka appends it to the end of the partition’s log and gives it the next offset number. Offsets help Kafka consumers know:
- Which messages have already been read.
- Which message should be read next.
- How to resume reading after a restart or failure.
Topic: orders, Partition 0
offset: 0 1 2 3 4 5
+-------+-------+-------+-------+-------+-------+
| msg A | msg B | msg C | msg D | msg E | msg F | <- new messages
+-------+-------+-------+-------+-------+-------+ are appended here
^
consumer's next read position
In simple words, a Kafka offset tracks message consumption. An offset is only meaningful together with its topic and partition: “offset 3” in partition 0 and “offset 3” in partition 1 are completely different messages.
2. Key Features of Kafka Offsets
| Feature | Explanation |
|---|---|
| Unique per partition | Offsets are unique only within a partition, not across the entire topic |
| Sequential order | Kafka assigns offsets in increasing order (0, 1, 2, 3 and so on), and they never change |
| Consumer controlled | Consumers decide which offset to read and when to save their progress; producers do not track it |
| Supports replay | Consumers can move back to an older offset and re-read messages, as long as they are still retained |
| Fault tolerant | Kafka stores committed offsets, so consumers can resume after a crash or restart |
| Independent consumption | Each consumer group has its own offsets, so different applications read the same messages at their own pace |
| Large range | Offsets are 64-bit numbers (Java long), so they never run out in practice |
3. Simple Example of Kafka Offsets
Let’s understand Kafka offsets with a simple scenario: the topic is order-topic and we look at Partition 0.
| Offset | Message |
|---|---|
| 0 | Order Created |
| 1 | Payment Received |
| 2 | Order Packed |
| 3 | Order Shipped |
If a consumer has successfully processed messages up to offset 1:
- Messages with offsets 0 and 1 are already consumed.
- The next message to read is offset 2, so the consumer commits 2.
- If the consumer crashes and restarts, Kafka lets it continue from offset 2 instead of starting from the beginning.
Remember: the committed offset is the offset of the next message to read, which is the last processed offset plus one.
4. Important Offset Positions in a Partition
Several named positions describe the state of a partition and of a consumer reading it:
| Position | Meaning |
|---|---|
| Log start offset | The oldest offset still available; older messages were removed by retention |
| Committed offset | The position a consumer group has saved; reading resumes here after a restart |
| Current position | The next offset the consumer will fetch in its current session; it can be ahead of the committed offset |
| High watermark | The offset after the last message copied to all in-sync replicas; consumers can read only up to here |
| Log end offset (LEO) | The offset the next new message will receive on the leader |
log start committed current high log end
offset offset position watermark offset
| | | | |
v v v v v
[ 100 ... 140 | 141 ... 145 | 146 ... 159 | 160 ... 161 ]
processed fetched, not available not yet fully
and saved yet committed to read replicated
Offsets in a partition are not always contiguous. Log compaction removes older records with the same key, and transactions add internal marker records, so a consumer may see offset 7 followed by offset 9. Code should never assume “next offset = previous + 1” when reading.
5. Where Kafka Stores Consumer Offsets
Consumer offsets are stored inside Kafka itself, in an internal compacted topic called __consumer_offsets (50 partitions by default). Each record says, in effect, “group G, topic T, partition P has committed offset N.”
- Offsets are stored per consumer group (
group.id), not per consumer instance, so any member of the group can continue where another left off. - A broker called the group coordinator handles commits and offset lookups for each group.
- Committed offsets for an empty group are deleted after
offsets.retention.minutes, which defaults to 7 days.
Very old Kafka versions kept offsets in ZooKeeper; modern clients and brokers always use __consumer_offsets.
6. Kafka Offset Commit
When a consumer finishes processing messages, it commits the offset. This tells Kafka, “I have safely processed messages up to this point.” Offsets can be committed in two ways:
| Type | How it works | Good for |
|---|---|---|
| Automatic commit | The client commits the latest polled positions at a fixed interval | Simple apps where an occasional duplicate or lost message is acceptable |
| Manual commit | The application commits explicitly after processing, using commitSync() or commitAsync() | Production systems that need control over when progress is saved |
Manual commit gives better control and is commonly used in production systems.
7. Automatic Offset Commit
# Automatic commit is on by default in the plain Java client
enable.auto.commit=true
# How often offsets are committed (default 5000 ms)
auto.commit.interval.ms=5000
Auto commit runs inside poll(): when the interval has passed, the client commits the positions returned by the previous poll. This is safe as long as your code finishes processing each batch before calling poll() again. Problems appear when you hand messages to other threads: the offsets can be committed before those threads finish, and a crash then loses the unfinished messages. If the consumer crashes between two commits, the messages since the last commit are delivered again.
8. Manual Offset Commit in Java
With enable.auto.commit=false, the application decides when to commit. The most common pattern is to process the whole batch and then commit:
package com.example.kafka;
import java.time.Duration;
import java.util.List;
import java.util.Properties;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.common.serialization.StringDeserializer;
public class ManualCommitConsumer {
public static void main(String[] args) {
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "order-service");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props)) {
consumer.subscribe(List.of("order-topic"));
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
for (ConsumerRecord<String, String> record : records) {
System.out.printf("partition=%d offset=%d value=%s%n",
record.partition(), record.offset(), record.value());
processOrder(record.value());
}
if (!records.isEmpty()) {
consumer.commitSync(); // saves the positions after this batch
}
}
}
}
private static void processOrder(String order) {
// business logic, for example saving the order to a database
}
}
| Method | Behaviour | Trade-off |
|---|---|---|
commitSync() | Waits for the broker to confirm and retries on recoverable errors | Reliable, but blocks the consumer briefly |
commitAsync() | Sends the commit and continues immediately; an optional callback reports the result | Faster, but does not retry; a failed commit is only reported |
A common combination is commitAsync() inside the loop for speed, plus one final commitSync() when the consumer shuts down.
Committing a specific offset
To save progress more often than once per batch, commit an exact offset per partition. Always commit offset + 1:
Map<TopicPartition, OffsetAndMetadata> toCommit = Map.of(
new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset() + 1)); // next offset to read
consumer.commitSync(toCommit);
9. Offsets and Delivery Guarantees
The moment you commit, before or after processing, decides what happens when a consumer crashes:
| Guarantee | When the offset is committed | Result after a crash |
|---|---|---|
| At-most-once | Before processing | Messages may be lost, never duplicated |
| At-least-once | After processing | Messages are never lost, but may be processed twice |
| Exactly-once | In the same Kafka transaction as the output messages | Each message affects the output once, for Kafka-to-Kafka pipelines |
At-least-once is the usual choice. Make processing idempotent, for example by ignoring an order ID that was already saved, so that a repeated message causes no harm.
For exactly-once in a read-process-write flow, a transactional producer includes the consumer’s offsets in its transaction:
producer.beginTransaction();
for (ConsumerRecord<String, String> record : records) {
producer.send(new ProducerRecord<>("order-events", record.key(), transform(record.value())));
}
producer.sendOffsetsToTransaction(nextOffsets(records), consumer.groupMetadata());
producer.commitTransaction(); // output and offsets are saved together, or not at all
Here nextOffsets(records) is a small helper that builds the map of each partition’s last offset plus one. Downstream consumers should use isolation.level=read_committed.
10. Where a New Consumer Starts: auto.offset.reset
When a consumer group has no committed offset for a partition (a brand-new group, or offsets that expired), the auto.offset.reset setting decides where to start:
| Value | Behaviour |
|---|---|
latest (default) | Start with new messages only; existing messages are skipped |
earliest | Start from the oldest available message |
none | Throw an exception, so the application must decide explicitly |
This setting is used only when no valid committed offset exists. Once the group has committed, it always resumes from the committed offset. It is also used when the committed offset is no longer available because retention already deleted those messages.
11. Reading from a Specific Offset
The consumer can move its position at any time with the seek methods. This is useful for replaying data, skipping a bad message, or starting from a point in time:
TopicPartition partition0 = new TopicPartition("order-topic", 0);
consumer.assign(List.of(partition0));
// 1. Jump to an exact offset
consumer.seek(partition0, 120L);
// 2. Go to the oldest or newest message
consumer.seekToBeginning(List.of(partition0));
consumer.seekToEnd(List.of(partition0));
// 3. Start from a point in time, for example one hour ago
long oneHourAgo = Instant.now().minus(Duration.ofHours(1)).toEpochMilli();
Map<TopicPartition, OffsetAndTimestamp> found =
consumer.offsetsForTimes(Map.of(partition0, oneHourAgo));
OffsetAndTimestamp result = found.get(partition0);
if (result != null) {
consumer.seek(partition0, result.offset());
}
A seek changes only the current position. The new position is saved for the group when the consumer next commits. When using subscribe() instead of assign(), seek inside a rebalance listener, after partitions have been assigned.
12. Resetting Offsets with the Command Line
The kafka-consumer-groups.sh tool shows and changes a group’s committed offsets. Stop all consumers in the group first; offsets of an active group cannot be reset.
# Show committed offsets and lag for a group
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--describe --group order-service
# Preview a reset to the earliest offsets (nothing changes yet)
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--group order-service --topic order-topic \
--reset-offsets --to-earliest --dry-run
# Apply the reset
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--group order-service --topic order-topic \
--reset-offsets --to-earliest --execute
| Reset option | Moves the committed offset to |
|---|---|
--to-earliest / --to-latest | The oldest or newest available offset |
--to-offset 500 | An exact offset |
--shift-by -100 | 100 messages back (or forward with a positive number) |
--to-datetime 2026-10-01T00:00:00.000 | The first message at or after that time |
--by-duration PT2H | The first message from two hours ago |
Without --execute, the tool only prints the planned result, so preview every reset first.
13. Consumer Lag: Measuring How Far Behind You Are
Consumer lag is how many messages a group still has to read in a partition:
lag = latest available offset (high watermark) - committed offset
Example: high watermark = 1,000, committed offset = 950 -> lag = 50
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG
order-service order-topic 0 950 1000 50
order-service order-topic 1 1203 1203 0
order-service order-topic 2 880 1410 530
- Low, stable lag is normal: consumers are keeping up.
- Steadily growing lag means consumers are too slow; add consumers (up to the number of partitions) or speed up processing.
- Lag on one partition only often means a stuck consumer or a “hot” key sending most traffic to that partition.
Track lag continuously with consumer metrics, a monitoring stack such as Prometheus with a Kafka exporter, or tools like Kafka UI, and alert when it keeps rising.
14. Offsets During a Rebalance
When consumers join or leave a group, Kafka rebalances the partitions among them. The new owner of a partition starts from its last committed offset, so anything processed but not yet committed is processed again. Commit before losing a partition with a ConsumerRebalanceListener:
Map<TopicPartition, OffsetAndMetadata> processed = new HashMap<>();
consumer.subscribe(List.of("order-topic"), new ConsumerRebalanceListener() {
@Override
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
consumer.commitSync(processed); // save progress before giving partitions away
processed.clear();
}
@Override
public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
// optional: seek to offsets stored elsewhere, for example in a database
}
});
// in the poll loop, after processing each record:
processed.put(new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset() + 1));
15. Managing Offsets in Spring Boot
Spring for Apache Kafka disables the client’s auto commit and lets the listener container commit for you. By default (AckMode.BATCH) it commits after all records from one poll are processed. For full control, switch to manual acknowledgement:
spring:
kafka:
bootstrap-servers: localhost:9092
consumer:
group-id: order-service
auto-offset-reset: earliest
listener:
ack-mode: manual
package com.example.kafka;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.stereotype.Component;
@Component
public class OrderListener {
private final OrderService orderService;
public OrderListener(OrderService orderService) {
this.orderService = orderService;
}
@KafkaListener(topics = "order-topic")
public void onOrder(ConsumerRecord<String, String> record, Acknowledgment ack) {
orderService.process(record.value());
ack.acknowledge(); // offset is committed only after successful processing
}
}
| Ack mode | When offsets are committed |
|---|---|
RECORD | After each record is processed |
BATCH (default) | After all records from one poll are processed |
MANUAL | After acknowledge(), committed with the next batch |
MANUAL_IMMEDIATE | Immediately when acknowledge() is called |
If the listener throws an exception, the offset is not committed. The default error handler retries the record a few times and then logs and skips it; configure a dead-letter topic so failed messages are kept for investigation.
16. Why Kafka Offsets Are Important
- Prevents message loss: consumers resume exactly where they stopped.
- Limits duplicate processing: committing after processing means only a small window of messages can be repeated.
- Enables reliable, scalable processing: any member of a group can take over a partition and continue from its offset.
- Supports recovery and reprocessing: moving offsets back replays history, for example after fixing a bug.
- Allows independent consumers: many applications read the same topic, each with its own offsets.
17. Best Practices and Common Mistakes
| Do | Avoid |
|---|---|
| Commit after processing for at-least-once | Committing before processing unless losing messages is acceptable |
| Make processing idempotent so repeats are harmless | Assuming each message is delivered exactly once by default |
Commit offset + 1 when committing manually | Committing the offset of the last processed record, which repeats it |
Set auto.offset.reset deliberately for new groups | Relying on the default latest and silently skipping old data |
Keep processing time below max.poll.interval.ms | Long processing that causes rebalances and repeated messages |
Preview offset resets with --dry-run | Resetting offsets of a group that is still running |
| Monitor consumer lag and alert on growth | Discovering backlogs only when users complain |
Use a new group.id to read a topic from scratch for testing | Reusing a production group ID in development |
18. Summary
A Kafka offset is a number that represents the position of a message in a partition. It helps consumers track what they have already read and what comes next. Consumer groups save their progress as committed offsets in the __consumer_offsets topic, either automatically or manually; committing after processing gives at-least-once delivery, and transactions give exactly-once for Kafka-to-Kafka flows. With auto.offset.reset, seek and the kafka-consumer-groups.sh tool you control where consumers start and can replay history, and consumer lag shows whether they are keeping up.
In short, Kafka offsets are the backbone of message tracking in Kafka: they make Kafka reliable, fault-tolerant and flexible by allowing safe recovery and message replay.
19. Frequently Asked Questions (FAQ)
Q1. What is an offset in Kafka in simple words?
An offset is the position number of a message inside a partition, like a page number in a book. The first message gets 0, the next 1, and so on. Consumers use offsets to remember where they stopped reading.
Q2. Is an offset unique across the whole topic?
No. Offsets are unique only within one partition. Partition 0 and partition 1 both have an offset 5, and they are different messages. A message is identified by topic, partition and offset together.
Q3. Who assigns offsets: the producer or the broker?
The broker that leads the partition assigns the offset when it appends the message to the log. The producer learns the offset afterwards from the RecordMetadata returned by send().
Q4. What is a committed offset?
It is the position a consumer group has saved for a partition. It means “everything before this offset is done; start here next time.” After a restart or rebalance, reading resumes from the committed offset.
Q5. Should I commit the last processed offset or the next one?
Commit the next one: the last processed offset plus one. If you processed offset 41, commit 42. Committing 41 would make the consumer read message 41 again after a restart.
new OffsetAndMetadata(record.offset() + 1)
Q6. Where does Kafka store consumer offsets?
In an internal, compacted Kafka topic called __consumer_offsets. Offsets are stored per consumer group, topic and partition, and the group coordinator broker manages them. Do not write to this topic directly.
Q7. Is auto commit enabled by default?
Yes, in the plain Java client enable.auto.commit is true and offsets are committed every 5 seconds (auto.commit.interval.ms=5000). Spring for Apache Kafka turns client auto commit off and lets its listener container commit instead.
Q8. Can auto commit lose messages?
It can if processing happens outside the poll loop, for example in other threads. Auto commit saves the polled positions on a later poll(), possibly before those threads finish. If the application then crashes, the unfinished messages are skipped. When processing completes inside the loop, auto commit gives at-least-once behaviour.
Q9. What is the difference between commitSync and commitAsync?
commitSync() blocks until the broker confirms and retries recoverable errors. commitAsync() returns immediately and reports the result to an optional callback, but does not retry. Many applications use commitAsync() in the loop and commitSync() on shutdown.
Q10. Why do I see duplicate messages after a consumer restart?
Because some messages were processed but not yet committed when the consumer stopped. After restarting, it resumes from the last committed offset and processes them again. This is normal at-least-once behaviour; make processing idempotent so repeats do no harm.
Q11. What does auto.offset.reset do?
It decides where a consumer starts when its group has no valid committed offset: latest (default) reads only new messages, earliest starts from the oldest available message, and none throws an error. It is ignored when a committed offset exists.
Q12. I set auto.offset.reset=earliest but the consumer does not read old messages. Why?
The group already has committed offsets, so the setting is not used. To read from the beginning, use a new group.id, reset the group’s offsets with kafka-consumer-groups.sh --reset-offsets --to-earliest, or call seekToBeginning() in code.
Q13. How can I re-read messages from a specific offset?
Call seek() on the consumer for that partition, then continue polling:
consumer.seek(new TopicPartition("order-topic", 0), 120L);
To start from a time instead, find the offset with offsetsForTimes() first.
Q14. How do I reset offsets for a consumer group?
Stop all consumers in the group, then use the command-line tool. Preview with --dry-run and apply with --execute:
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--group order-service --topic order-topic \
--reset-offsets --to-earliest --execute
Q15. What is consumer lag?
Lag is the number of messages a group still has to read: the latest available offset minus the committed offset. Steady low lag is healthy; growing lag means consumers cannot keep up and need more instances or faster processing.
Q16. What happens to offsets when messages are deleted by retention?
Offsets are never reused or renumbered. The log start offset simply moves forward. If a group’s committed offset points to deleted messages, the consumer falls back to auto.offset.reset to choose a new starting point.
Q17. Do committed offsets expire?
Yes. When a consumer group has no active members, its committed offsets are deleted after offsets.retention.minutes, which defaults to 7 days. When the group starts again, auto.offset.reset decides where it begins.
Q18. Why are some offsets missing or not continuous?
Gaps are normal. Log compaction removes older records with the same key, and transactional producers write internal commit and abort markers that consumers never see. Offsets always increase, but not always by exactly one.
Q19. Can two consumer groups have different offsets for the same partition?
Yes. Each group stores its own offsets, so one group can be at offset 10 while another is at 5,000 on the same partition. This is how several applications read the same topic independently.
Q20. How do I commit offsets manually in Spring Boot?
Set spring.kafka.listener.ack-mode=manual (or manual_immediate), add an Acknowledgment parameter to the listener, and call it after successful processing:
@KafkaListener(topics = "order-topic")
public void onOrder(String order, Acknowledgment ack) {
orderService.process(order);
ack.acknowledge();
}