Kafka Streams
Kafka Streams is a Java library for programs that read topics, compute, and write topics. It gives you grouping, counting, windows and joins, and it keeps the state those need safe in Kafka itself.
Try it: count words as they arrive
This is the program you run in the lab. Each sentence is one input record. The table holds the current count for each word, and every change to the table is sent to the output topic.
table: current count for each word
output topic: every change, in order
The table has one row for each word. The output topic has one record for each change. They are two views of the same data.
What it is and why it exists
With only a consumer and a producer, simple processing is easy: read a record, change it, write it. Harder tasks need state. To count orders for each customer you must remember the counts. To join an order with its customer you must remember the customers. That state must survive a crash, must move when partitions move to another instance, and must stay consistent with the offsets you commit.
Kafka Streams solves this as a library. You add it to a normal Java application. There is no separate processing cluster. You describe the processing as a chain of steps, and the library runs it, keeps the state in a local store on disk, and writes every state change to a Kafka topic so that the state can be rebuilt anywhere.
To scale, you start more copies of the same application. They form a consumer group (module 03) and share the input partitions. To get exactly-once, you set one option and the library uses transactions (module 06).
The one-sentence version. Kafka Streams turns topics into streams and tables, runs your steps once for each input partition, and backs every local state store with a compacted topic.
Mental model
Streams and tables
The same topic can be read in two ways.
Stream (KStream) | Table (KTable) | |
|---|---|---|
| Each record is | An event: something happened. | An update: the value for this key is now this. |
| Two records with the same key | Two separate facts. | The second replaces the first. |
| A null value | An event with no content. | A delete of that key. |
| Example | Each payment made. | The current balance of each account. |
They convert into each other. If you replay a stream of updates and keep the latest for each key, you get a table. If you write down every change to a table, you get a stream. The demo at the top shows both at once. A compacted topic (module 01) is a table stored in Kafka.
The program is a graph
You describe steps: read a topic, split, filter, group, count, write a topic. Kafka Streams calls this the topology. The word-count program is short enough to read whole:
StreamsBuilder builder = new StreamsBuilder();
KStream<String, String> lines = builder.stream("streams-plaintext-input");
KTable<String, Long> counts = lines
.flatMapValues(line -> Arrays.asList(line.toLowerCase().split(" "))) // one record for each word
.groupBy((key, word) -> word) // the word becomes the key
.count(); // a table: word → count
counts.toStream().to("streams-wordcount-output", Produced.with(Serdes.String(), Serdes.Long()));
Two things happen that the code does not show.
- A repartition topic.
groupBychanges the key from "nothing" to "the word". Counting needs all records for one word in one partition. So the library writes the records to an internal topic keyed by word and reads them back. The program is now two parts with a topic between them. - A changelog topic.
countneeds a store. Every time a count changes, the library also writes the new value to an internal compacted topic.
Time and windows
"Count for each word" grows for ever. Often you want "count in each minute". A window cuts the stream into pieces by time. The time is the record's own timestamp, the event time, not the clock of the machine that processes it. So a record that arrives late still goes into the window where it belongs.
Try it: three kinds of window on the same six events
The dots are events at seconds 1, 4, 6, 14, 16 and 31. Each bar is one window with the number of events in it.
Joins
A join combines records from two sources that have the same key. The most common one enriches a stream with a table: each order gets the current data of its customer. The table side is a lookup. The stream side drives the output. So the order of arrival matters:
Try it: join orders with customers
An inner join of a stream of orders with a table of customers. Press the buttons in different orders.
customers table
joined output
How it works internally
Tasks: the unit of work
The library splits the topology into sub-topologies at each repartition topic. For each sub-topology it creates one task for each input partition. A task has an id of two numbers: sub-topology and partition. In the lab, three input partitions and two sub-topologies gave six tasks, and six state directories named 0_0 to 1_2.
A task owns its partition and its piece of the state. Tasks are assigned to stream threads across all running instances by the consumer group. So the partition count of the input topic is the upper limit of parallel work, as for any consumer group.
State stores and changelogs
- A step that needs memory, such as
count, gets a state store. By default this is RocksDB, a key-value store that keeps its data in files on local disk. - Each write to the store is also sent to the store's changelog topic, a compacted topic with the same number of partitions as the task's input.
- On a commit, the library flushes the store, waits for the changelog writes, and commits the input offsets. With exactly-once these are one transaction.
- When a task moves to an instance that does not have its store, the instance reads the changelog partition from the start and rebuilds the store. This is restoration. The task does not process until it is finished.
In the lab I deleted the local state directory and restarted the program. The next sentence gave "the 6", continuing from 5: the counts came back from the changelog.
Restoration of a large store can take minutes. A standby replica is a second instance that keeps reading the changelog to hold a warm copy, so that a failover is fast. Keeping local disks across restarts has the same effect for planned restarts.
Why output can show fewer updates than you expect
A cache sits in front of each store. Several updates to the same key within one commit interval are merged, and only the last is sent on. The example program sets the cache to zero so that you see every change. With the default 10 MiB cache and 30-second commit interval you would see fewer, later records. The final values are the same.
Late records and the grace period
Records can arrive out of order. A window stays open for late records for its grace period after its end. A record that arrives later than that is dropped and counted in a metric. A longer grace period accepts more late data and makes final results later.
Co-partitioning
For a join, the task for partition 3 of one topic must also have partition 3 of the other, and the same key must be in the same partition on both sides. So both topics need the same number of partitions and the same partitioning by key. The library checks the counts at start and stops if they differ. A GlobalKTable avoids the rule: every instance loads the whole table. Use it for small reference data.
Exactly-once
With processing.guarantee=exactly_once_v2, each commit is a transaction that covers the output records, the changelog records and the input offsets. The commit interval then defaults to 100 milliseconds. unverified: the 100 ms value is from memory
Every relevant config
Defaults are from the Kafka 4.3 Kafka Streams configuration reference. A Streams application also accepts every producer and consumer setting from modules 02 and 03.
| Streams config | Default | What it does | When to change it |
|---|---|---|---|
application.id | none (required) | The name of the application. It is the consumer group id and the prefix of every internal topic and state directory. | Choose once. A new id means a new application that starts from nothing. |
bootstrap.servers | none (required) | Broker addresses. | — |
processing.guarantee | at_least_once | exactly_once_v2 turns on transactions. | When duplicates in output or state are not acceptable. |
num.stream.threads | 1 | Processing threads in this instance. | Raise to use more processor cores, up to the number of tasks. |
num.standby.replicas | 0 | Warm copies of each state store on other instances. | Set 1 for stateful applications that must fail over in seconds. |
state.dir | the Java temporary directory | Where local state is kept. | Always set it to a durable disk. The temporary directory can be cleared by the operating system; the library warns about this at start. |
replication.factor | −1 (use the broker default) | Copies of the internal topics. | Make sure the result is 3 in production. |
commit.interval.ms | 30000 (30 seconds) | How often state is flushed and offsets are committed. | Lower for fresher output and less repeated work after a crash; higher for throughput. |
statestore.cache.max.bytes | 10485760 (10 MiB) | Cache that merges updates to the same key before they are sent on. | Set 0 to see every update. Raise to reduce downstream load. |
max.task.idle.ms | 0 | How long a task with several inputs waits for data on all of them before it processes what it has. | Raise it for joins, so that records are processed in time order across both inputs. |
default.key.serde, default.value.serde | null | How keys and values become bytes when a step does not say. | Set them, or state the serde at each step. |
default.timestamp.extractor | FailOnInvalidTimestamp | Where event time comes from. The default uses the record's timestamp and stops on an invalid one. | Write your own to use a time inside the value. |
deserialization.exception.handler | LogAndFailExceptionHandler | What to do with a record that cannot be read. | Use a handler that logs and continues, or sends the record to a dead-letter topic, so that one bad record does not stop the application. |
processing.exception.handler | LogAndFailProcessingExceptionHandler | What to do when your code throws. | As above, for errors you can skip. |
production.exception.handler | DefaultProductionExceptionHandler | What to do when a write to Kafka fails. | Rarely. |
topology.optimization | none | all lets the library merge repartition topics and reuse source topics as changelogs. | Turn on for new applications. Turning it on later changes internal topics. |
application.server | empty | This instance's host and port, shared with other instances so that a query can be sent to the instance that holds a key. | Set it when you query state stores from outside. |
acceptable.recovery.lag | 10000 | An instance whose copy of a store is at most this many records behind counts as caught up for task assignment. | Rarely. |
max.warmup.replicas | 2 | How many extra copies may be warmed at once before tasks move to a new instance. | Raise to scale out faster at more load. |
probing.rebalance.interval.ms | 600000 (10 minutes) | How often to check if warmed copies are ready so that tasks can move. | Rarely. |
state.cleanup.delay.ms | 600000 (10 minutes) | Wait before deleting local state of a task that moved away. | Rarely. |
task.timeout.ms | 300000 (5 minutes) | How long a task may fail on timeouts before the application gives up. | Raise to ride through longer broker problems. |
windowstore.changelog.additional.retention.ms | 86400000 (1 day) | Extra retention on window changelog topics beyond the window's own retention. | Rarely. |
group.protocol | classic | streams uses a newer protocol where the broker assigns tasks. | The lab broker lists streams as accepted. Check its status for your version before use. unverified |
rack.aware.assignment.strategy | none | Prefers tasks on instances in the same rack as the partition leaders. | Multi-zone deployments. |
poll.ms, buffered.records.per.partition | 100, 1000 | How long to wait for input, and how many records to hold for each partition. | Rarely. |
Failure scenarios
The state store cannot load its native library
- What breaks
- The application starts, creates its internal topics, then shuts down. No output.
- You observe
- In the lab, with the official
apache/kafkaimage: "UnsatisfiedLinkError: … librocksdbjni….so: Error loading shared library libstdc++.so.6: No such file or directory". - Cause
- RocksDB is native code and needs the C++ standard library. The small Alpine Linux base image does not include it.
- Fix
- Install the library in the image (
apk add libstdc++on Alpine), or use a base image that has it.
A restart takes many minutes
- What breaks
- After a deployment or a crash, tasks do not process while their stores are rebuilt from the changelog.
- You observe
- Lag grows on the input. Log lines about restoration. The application reports the state "rebalancing" for a long time.
- Fix
- Keep
state.diron a disk that survives restarts, so that only the missing tail is replayed. Add a standby replica. Keep stores small: use windows with retention instead of unbounded tables where you can.
One bad record stops the application
- What breaks
- A record cannot be deserialized. With the default handler the thread stops, the task moves to another instance, which reads the same record and stops too.
- Fix
- Set a deserialization handler that logs and continues or forwards the record to a dead-letter topic. Decide which is right for your data: skipping silently is also a kind of data loss.
Topics for a join are not co-partitioned
- What breaks
- The application refuses to start with a topology error about different partition counts. Worse, if the counts match but the producers used different partitioners, it starts and silently misses matches.
- Fix
- Give both topics the same partition count and write both with the default partitioner on the same key. Or repartition one side inside the topology. Or use a
GlobalKTablefor the lookup side.
The join misses records at start-up
- What breaks
- Orders are joined against a customers table that is not loaded yet, and are dropped or sent on without customer data.
- Cause
- With
max.task.idle.ms=0a task processes whichever input has data. The stream may run ahead of the table. - Fix
- Raise
max.task.idle.msso that the task waits and takes records in timestamp order. Make sure table records carry timestamps earlier than the events that need them.
A code change loses the state
- What breaks
- After a release, counts start again from zero.
- Cause
- Internal topic names contain generated numbers, such as
KSTREAM-AGGREGATE-STATE-STORE-0000000003. Adding a step before it changes the number, so the new version looks for a different, empty changelog. - Fix
- Give every stateful step and repartition an explicit name in the code. Then the topic names stay the same when the topology changes around them.
Late records are dropped
- What breaks
- Window results are lower than the true numbers.
- Cause
- Records arrive after the window's grace period, for example from a device that was offline.
- Fix
- Watch the dropped-records metric. Lengthen the grace period to match how late your data really is, and accept later final results.
Hands-on lab
Every command below was run on 4 October 2026 against the lab cluster. It uses the word-count example that ships inside the Kafka image, so you do not need to write or build Java code.
k() { docker exec -i kafka-1 /opt/kafka/bin/"$@"; }
B=kafka-1:19092Step 1. Prepare
Create the two topics. Install the library that RocksDB needs; without it the example stops with the error shown in the failure section. Write a small configuration file so that the example finds the brokers.
k kafka-topics.sh --bootstrap-server $B --create --topic streams-plaintext-input --partitions 3 --replication-factor 3
k kafka-topics.sh --bootstrap-server $B --create --topic streams-wordcount-output --partitions 3 \
--replication-factor 3 --config cleanup.policy=compact
docker exec -u root kafka-1 apk add --no-cache libstdc++
docker exec kafka-1 sh -c 'printf "bootstrap.servers=kafka-1:19092\n" > /tmp/streams.properties'Step 2. Start the application and send sentences
docker exec -d kafka-1 sh -c '/opt/kafka/bin/kafka-run-class.sh \
org.apache.kafka.streams.examples.wordcount.WordCountDemo /tmp/streams.properties > /tmp/wordcount.log 2>&1'
# wait 14 seconds
printf 'the quick brown fox\nthe lazy dog\nthe fox\nthe lazy dog\nthe fox\n' | \
k kafka-console-producer.sh --bootstrap-server $B --topic streams-plaintext-inputStep 3. Read the output
The counts are stored as 8-byte numbers, so tell the consumer how to read the value.
k kafka-console-consumer.sh --bootstrap-server $B --topic streams-wordcount-output --from-beginning \
--formatter-property print.key=true \
--formatter-property value.deserializer=org.apache.kafka.common.serialization.LongDeserializerthe 1 the 2 dog 1 the 3 the 4 dog 2 the 5 quick 1 brown 1 fox 1 lazy 1 fox 2 lazy 2 fox 3
Every change to the table is one record: "the" appears five times with counts 1 to 5. The words are not in input order across the whole output, because each word went to one of three partitions and the consumer reads the partitions one after another. Within one word the order is always right.
Step 4. Find what the library created
k kafka-topics.sh --bootstrap-server $B --list | grep streams
k kafka-topics.sh --bootstrap-server $B --describe --topic \
streams-wordcount-KSTREAM-AGGREGATE-STATE-STORE-0000000003-changelog | head -1
k kafka-consumer-groups.sh --bootstrap-server $B --describe --group streams-wordcount
docker exec kafka-1 sh -c 'ls /tmp/kafka-streams/streams-wordcount/'streams-plaintext-input streams-wordcount-KSTREAM-AGGREGATE-STATE-STORE-0000000003-changelog streams-wordcount-KSTREAM-AGGREGATE-STATE-STORE-0000000003-repartition streams-wordcount-output changelog: PartitionCount: 3 Configs: min.insync.replicas=2,cleanup.policy=compact,… repartition: Configs: cleanup.policy=delete,segment.bytes=52428800,retention.ms=-1,… TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG streams-plaintext-input 2 5 5 0 streams-wordcount-…-repartition 1 7 7 0 streams-wordcount-…-repartition 2 7 7 0 0_0 0_1 0_2 1_0 1_1 1_2 kafka-streams-process-metadata
Read this carefully; it contains the whole design.
- Two internal topics, both prefixed with the application id. The changelog is compacted. The repartition topic has unlimited retention; the library deletes records from it itself after they are processed.
- The application is a consumer group named after its id. It consumes the input topic and its own repartition topic.
- Five sentences went to input partition 2. They became 14 words, spread by key over the repartition partitions: 7 and 7.
- Six task directories: sub-topology 0 (split into words) and sub-topology 1 (count), each for three partitions.
Step 5. Destroy the local state and watch it come back
docker exec kafka-1 sh -c "pkill -f WordCountDemo; sleep 3; rm -rf /tmp/kafka-streams"
docker exec -d kafka-1 sh -c '/opt/kafka/bin/kafka-run-class.sh \
org.apache.kafka.streams.examples.wordcount.WordCountDemo /tmp/streams.properties > /tmp/wordcount2.log 2>&1'
# wait 14 seconds
echo 'the end' | k kafka-console-producer.sh --bootstrap-server $B --topic streams-plaintext-input
# wait 10 seconds, then read the output topic again and look at the last linesfox 3 the 6 end 1
The local store was deleted, yet "the" went from 5 to 6. The application rebuilt the store from the changelog topic before it processed the new sentence.
Step 6. Stop and reset
docker exec kafka-1 sh -c "pkill -f WordCountDemo"
# wait about 45 seconds for the group to become empty
k kafka-streams-application-reset.sh --bootstrap-server $B --application-id streams-wordcount \
--input-topics streams-plaintext-input --dry-runThe reset tool moves the input offsets back to the start and deletes the internal topics, so that the application can process everything again. Run it with --dry-run first. It refuses to run while the group has members: when I ran it straight after the kill, it failed with "Consumer group 'streams-wordcount' is still active". The reset run after the wait was not done in my session.
Common mistakes and misconceptions
| People think | What is true |
|---|---|
| Kafka Streams runs on the brokers. | It is a library inside your own application. The brokers only store topics. |
| State is kept in memory and lost on a crash. | It is on local disk and every change is in a changelog topic. |
| Local state is the source of truth. | The changelog topic is. The local store is a cache that can be rebuilt. |
| A table gives one output record for each key. | It gives one for each change, minus those merged by the cache. |
| Windows use the time the record is processed. | They use the record's timestamp by default. |
| More instances always means faster. | Only up to the number of tasks, which comes from the input partition count. |
| Changing the application id is harmless. | It creates a new consumer group and new, empty internal topics. All state starts again. |
| Any two topics with the same keys can be joined. | They must also have the same partition count and the same partitioner. |
Staff-level questions
When do you choose Kafka Streams, and when something else?
Choose it when the input and output are Kafka topics, the team writes Java or another language on the Java runtime, and you want to deploy the processing like any other service.
Look elsewhere when you need another language, when sources and sinks are mostly not Kafka, when one job must scale beyond what a partition count allows, or when analysts need to write the logic in SQL. Apache Flink covers those cases, at the price of a separate cluster to run.
How do you size and deploy a stateful Streams application?
Estimate the store: number of keys times value size, plus overhead. That is the local disk for each instance's share and also the size of the changelog. Restoration time is roughly the changelog size divided by the read rate; if that is longer than you can accept, add a standby replica and keep disks across restarts.
Deploy with stable identities and durable volumes. Set state.dir on that volume. Restart one instance at a time and wait for the application to return to the running state.
What does exactly-once cost in Streams?
Commits happen far more often, so more transactions and markers. Output becomes visible to committed readers only at each commit. After an unclean stop, the local store may not match the last committed transaction, so it is wiped and restored from the changelog, which makes crash recovery slower.
It is worth it when the output is totals or state that later steps trust. For a pipeline whose sink already ignores repeats, at-least-once is simpler.
A join must use a 50-million-row reference table. KTable or GlobalKTable?
A GlobalKTable loads all 50 million rows into every instance: simple, no co-partitioning, but each instance needs the disk and a long first load. A KTable gives each instance only its partitions, and needs both topics co-partitioned on the join key.
For a table this large, use a KTable and repartition the stream to the table's key if needed. Keep GlobalKTable for small data such as country codes, or when the stream must be joined on a field that is not its key and repartitioning is too costly.
How do you change a running topology safely?
Name every stateful operator and repartition step from the first release. Then most changes keep the same internal topics.
For a change that alters what the state means, such as a different aggregation, the old state is wrong for the new code. Deploy it as a new application id that reads from the start, let it catch up beside the old one, then switch consumers of the output and retire the old one.
How do other services read the state?
Two ways. Write the table to an output topic and let each service keep its own copy: simple and decoupled. Or query the stores directly inside the application, with application.server set so that a request can be forwarded to the instance that owns the key.
Direct queries save a copy of the data, but your service must handle the moments when a store is moving or restoring.
Self-test
1. Where does a Kafka Streams application run?
In your own Java process. There is no separate processing cluster.
2. What is the difference between a stream and a table?
In a stream each record is a separate event. In a table each record replaces the earlier value for its key.
3. What does application.id control?
The consumer group id, the prefix of internal topics, and the name of the state directory.
4. Why does groupBy on a new key create a repartition topic?
All records with the same new key must reach the same task. Writing them to a topic keyed by the new key does that.
5. What is a changelog topic?
A compacted internal topic that receives every change to one state store, so that the store can be rebuilt.
6. What is a task, and how many does an application have?
One unit of processing for one sub-topology and one input partition. The total is the sum, over sub-topologies, of their input partition counts.
7. What does a standby replica do?
It keeps a warm copy of a state store on another instance by reading the changelog, so that failover does not wait for a full restoration.
8. Name the three kinds of window in the demo.
Tumbling: fixed, no overlap. Hopping: fixed, overlapping. Session: ends after a gap with no events.
9. What must be true of two topics before a stream-table join?
The same number of partitions and the same partitioning by key.
10. Which setting turns on exactly-once?
processing.guarantee=exactly_once_v2.