Definition
A Kafka partition is stored on disk as a directory of log segment files with matching indexes.
- Segment file (.log) – holds a contiguous range of records by offset, typically up to 1 GB.
- Offset index (.index) – a sparse map from logical offset to byte position in the .log file, enabling fast binary-search lookups.
- Time index (.timeindex) – a sparse map from timestamp to offset, powering time-based seeks and retention.
- Active segment – the newest segment that is still being written to; closed segments are immutable.
- Segment rolling – the active segment is closed and a new one opened when it hits
segment.bytesorsegment.ms.
Imagine National Bank of India keeps a partition's transaction history in a filing cabinet. Each drawer is a segment holding consecutive entries. Each drawer has a small index card on the front that says entry number so-and-so starts at this spot (the offset index), and a second card mapping dates to entry numbers (the time index). Clerks only add new entries to the front drawer that is currently open. Older drawers are sealed. When cleaning up, the bank either throws out whole sealed drawers that are too old (retention) or rewrites them to keep only the latest entry per account (compaction), never touching the open drawer.
Understanding the Concepts
What is the on-disk layout of a Kafka partition?
For a topic partition, Kafka creates a directory named like settlements-0 for partition zero. Inside it are groups of three files per segment, named by the base offset. The .log file is the actual record data. The .index file is the offset index. The .timeindex file is the time index.
# List files in a partition directory
ls -la /data/kafka/settlements-0/
# Output:
# 00000000000000000000.log (1 GB -- first segment)
# 00000000000000000000.index
# 00000000000000000000.timeindex
# 00000000000001048576.log (1 GB -- second segment)
# 00000000000001048576.index
# 00000000000001048576.timeindex
# 00000000000002097152.log (400 MB -- active segment, still growing)
# 00000000000002097152.index
# 00000000000002097152.timeindex
How does the offset index enable fast lookups?
The .log file can be a gigabyte. To find a record at offset N without scanning the entire file, Kafka keeps the offset index, a sparse map from logical offset to byte position. It has one entry roughly every index.interval.bytes (default 4 KB). To read offset N, Kafka binary-searches the index for the nearest entry at or below N, seeks to that byte position, and scans forward a short distance.
package com.bank.kafka;
/**
* Conceptual: how the offset index lookup works.
*/
public class OffsetIndexLookupDemo {
public static void main(String[] args) {
// Sparse index entries (simplified)
// Offset -> Byte position in .log file
// 0 -> 0
// 100 -> 32768
// 200 -> 65536
// 300 -> 98304
int targetOffset = 250;
// Binary search: nearest entry at or below 250 is offset 200 at position 65536
int seekPosition = 65536;
System.out.println("Seeking to byte " + seekPosition + " for offset " + targetOffset);
System.out.println("Then scanning forward ~50 records to find offset 250");
// Much faster than scanning from byte 0!
}
}
Seeking to byte 65536 for offset 250
Then scanning forward ~50 records to find offset 250
How does the time index work?
The time index maps timestamps to offsets. This powers operations like seek-to-timestamp and time-based retention, which needs to know the latest timestamp in a segment to decide if it has aged out.
package com.bank.kafka;
import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.serialization.StringDeserializer;
import java.util.*;
public class TimeIndexSeekDemo {
public static void main(String[] args) {
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka-1:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "replay-group");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props)) {
TopicPartition tp = new TopicPartition("settlements", 0);
consumer.assign(List.of(tp));
// Seek to 1 hour ago using the time index
long oneHourAgo = System.currentTimeMillis() - 3_600_000;
Map<TopicPartition, Long> timestamps = Map.of(tp, oneHourAgo);
Map<TopicPartition, OffsetAndTimestamp> result = consumer.offsetsForTimes(timestamps);
if (result.get(tp) != null) {
consumer.seek(tp, result.get(tp).offset());
System.out.println("Seeked to offset " + result.get(tp).offset()
+ " (1 hour ago)");
}
}
}
}
Seeked to offset 1048200 (1 hour ago)
How does segment rolling work?
The active segment keeps growing as producers append. Kafka closes it and starts a new active segment when the active segment reaches segment.bytes in size or segment.ms in age, whichever comes first. Closed segments become immutable.
# Configure segment rolling for the settlements topic
kafka-configs.sh --alter --topic settlements \
--bootstrap-server kafka-1:9092 \
--add-config segment.bytes=268435456,segment.ms=3600000
# segment.bytes=256 MB: roll when segment reaches 256 MB
# segment.ms=1 hour: roll after 1 hour even if not full
How does delete retention interact with segments?
With cleanup.policy=delete, Kafka checks closed segments against the retention policy. A segment is eligible for deletion when all its records are older than retention.ms (judged by the segment's largest timestamp) or when the partition's total size exceeds retention.bytes. Retention deletes entire segment files, never individual records.
# Delete retention: remove segments older than 3 days
kafka-configs.sh --alter --topic settlements \
--bootstrap-server kafka-1:9092 \
--add-config cleanup.policy=delete,retention.ms=259200000
# Result: segment with newest timestamp older than 3 days -> deleted
# Segment with newest timestamp = 2 days ago -> kept
How does compaction interact with segments?
With cleanup.policy=compact, the log cleaner scans closed segments, builds a map of the latest offset per key, and rewrites them to keep only the most recent record per key. Compaction preserves offsets, so the log has gaps in offset numbering after compaction.
# Compaction: keep only latest value per account
kafka-configs.sh --alter --topic account.state \
--bootstrap-server kafka-1:9092 \
--add-config cleanup.policy=compact,min.cleanable.dirty.ratio=0.3
# Before compaction:
# offset 0: SAV2001={balance:100000}
# offset 1: SAV3001={balance:200000}
# offset 2: SAV2001={balance:90000} <- supersedes offset 0
# After compaction:
# offset 1: SAV3001={balance:200000}
# offset 2: SAV2001={balance:90000}
# (offset 0 removed, gap is normal)
Why is the active segment never cleaned?
Both retention and compaction only operate on closed, immutable segments. The active segment is still being written to, so it is always excluded. This means a very low-traffic partition whose active segment rarely rolls can retain data far longer than retention.ms suggests, until segment.ms forces a roll.
# Problem: low-traffic topic retains data beyond retention.ms
# The active segment has not rolled because segment.bytes is 1 GB
# and the partition only received 10 MB in the last 30 days
# Fix: set segment.ms so the active segment rolls on a schedule
kafka-configs.sh --alter --topic low-traffic-alerts \
--bootstrap-server kafka-1:9092 \
--add-config segment.ms=86400000 # roll daily regardless of size
What are the trade-offs of segment size?
Smaller segments give finer retention precision and smoother cleanup bursts, but they add file-handle and index overhead. Larger segments are more efficient for steady-state append and read but make retention coarser and cleanup burstier.
# Too small: excessive file handles
# 1 TB partition / 16 MB segments = 65,536 segments = 196,608 files
# ulimit -n may not be enough!
# Too large: coarse retention
# 1 TB partition / 1 GB segments = 1,024 segments
# Cannot delete a 1 GB segment until its last record ages out
# Good balance: 256 MB segments
# 1 TB partition / 256 MB = 4,096 segments = 12,288 files
# Fine-grained retention, manageable file count
What should a senior engineer know?
After a broker restart, Kafka may rebuild or sanity-check indexes from the log, which is why index files are treated as reconstructable rather than authoritative. The index.interval.bytes setting controls index sparsity: a smaller interval means more index entries and faster lookups but a larger index file. Segment size is the central tuning knob that balances steady-state efficiency against cleanup precision.
# Rebuild indexes on startup (automatic if corrupted)
# Log message: "Found a corrupted index, rebuilding..."
# Monitor segment and index health
kafka-log-dirs.sh --describe --bootstrap-server kafka-1:9092 \
--topic-list settlements
# Key JMX metric
# kafka.log:type=Log,name=NumLogSegments,topic=settlements,partition=0
Visual Overview
Partition Directory: /data/kafka/settlements-0/ ┌───────────────────────────────────────────────────────┐ │ Segment 0 (closed, immutable) │ │ 00000000000000000000.log (records 0-1048575) │ │ 00000000000000000000.index (offset -> byte pos) │ │ 00000000000000000000.timeindex (timestamp -> offset) │ ├───────────────────────────────────────────────────────┤ │ Segment 1 (closed, immutable) │ │ 00000000000001048576.log (records 1048576-...) │ │ 00000000000001048576.index │ │ 00000000000001048576.timeindex │ ├───────────────────────────────────────────────────────┤ │ Active Segment (open, being written to) │ │ 00000000000002097152.log ← producers append │ │ 00000000000002097152.index │ │ 00000000000002097152.timeindex │ └───────────────────────────────────────────────────────┘ Offset Index (sparse): ┌────────┬────────────┐ │ Offset │ Byte Pos │ ├────────┼────────────┤ │ 0 │ 0 │ │ 100 │ 32768 │ ← binary search finds nearest │ 200 │ 65536 │ entry, then scan forward │ 300 │ 98304 │ └────────┴────────────┘ Cleanup: Delete retention: whole closed segment removed Compaction: closed segment rewritten (latest per key) Active segment: NEVER touched by cleanup
Key Takeaway
A Kafka partition is stored on disk as a directory of log segment files. Each segment is a .log file holding records in offset order, paired with a sparse .index offset index and a .timeindex time index, all named by the segment's base offset. Only the active segment is written to; closed segments are immutable, and that immutability is the foundation that makes cleanup safe.
The offset index enables fast lookups without scanning gigabyte files. It maps logical offsets to byte positions at regular intervals, so Kafka binary-searches the index, seeks to the right spot, and scans forward a short distance. The time index does the same for timestamps, powering time-based seeks and retention decisions.
Retention and compaction both operate at segment granularity on closed segments and always skip the active segment. Delete retention removes whole segments when their records age out. Compaction rewrites closed segments to keep only the latest record per key, preserving offsets and leaving gaps. On the National Bank of India settlement topic, Pankaj's team uses delete retention so old history is trimmed by dropping whole aged segments, while the account-state topic uses compaction so the latest balance per account is always retained.
Remember one sentence: a partition is a sequence of immutable segments plus one active segment, each with a sparse offset and time index, and all cleanup is segment-oriented.
---