Kafka Streams: KStream vs KTable vs GlobalKTable
Fast recall
Quick revision
Core answer
Whether you understand stream-table duality and can pick the right abstraction for events vs current-state vs broadcast lookups
Using KStream for what's really a stateful per-key view and ending up reimplementing aggregation, or using GlobalKTable for large datasets where memory blows up on every instance
Explain stream-table duality, windowed aggregations (tumbling/hopping/session), state stores backed by compacted topics, and the partitioning requirements for KStream-KTable joins
Interview-ready guide
Interview answer
Kafka Streams gives you three abstractions over the same topic, and the right choice depends on what each record means.
KStream:
- Treats records as immutable facts — clicks, payments, orders-placed
- Operations like
map,filter,flatMapproduce new records; aggregations require explicit windowing because there's no implicit per-key state
KTable:
- Treats the topic as a changelog — each record is the latest value for its key, conceptually a table row
- Backed by a local RocksDB state store and a compacted changelog topic, so point lookups by key are local
- Use it for current state per key like user profiles or account balances
GlobalKTable:
- A KTable that's fully replicated to every Streams instance instead of partitioned
- Use it for small reference data — country codes, product catalogs — where any task might need to look up by an arbitrary field of the input record
- The cost is memory: every instance holds the full dataset, so it has to genuinely be small
Windowing:
- Tumbling — non-overlapping fixed buckets, for hourly or daily counts
- Hopping — fixed size with overlap, for moving-average dashboards
- Session — activity-driven, for user sessions and IoT bursts
- Always set
grace.msexplicitly for late records, otherwise they silently disappear from aggregations
Production gotchas:
- KStream-KTable joins require co-partitioning — same partition count and partitioner — and Streams doesn't enforce it; KStream-GlobalKTable joins don't
- Set
num.standby.replicas >= 1so failover doesn't trigger a multi-minute state-store restore from the changelog topic processing.guarantee=exactly_once_v2is correct but slower; defaultat_least_onceis fine for analytics
In Spring Boot the cleanest way to wire this is the Spring Cloud Stream Kafka Streams binder — declare the topology as a Function bean and configure inputs, outputs, and processing guarantees via application properties.
Kafka Streams: KStream vs KTable vs GlobalKTable
Direct Answer
KStream models a Kafka topic as an unbounded sequence of independent records — each record is a fact (e.g. 'user clicked X at time T'). KTable models the same topic as a changelog: each record is the latest value for its key, conceptually a row in a continuously updated table (built on a compacted topic). GlobalKTable is a KTable replicated fully to every Streams instance instead of partitioned, used for broadcast lookups against small reference data. KStream is for events; KTable is for current state per key; GlobalKTable is for small lookup tables that every task needs.
Code Example
Spring Cloud Stream's Kafka Streams binder lets you declare topology functions and configure them via properties. A representative flow: enrich incoming clicks (KStream) with users (KTable, partitioned by userId) and countries (GlobalKTable, broadcast lookup), then aggregate per country in 5-minute tumbling windows.
@Configuration
public class ClickAnalyticsTopology {
@Bean
public Function<KStream<String, Click>,
Function<KTable<String, User>,
Function<GlobalKTable<String, Country>,
KStream<Windowed<String>, Long>>>>
enrichAndCount() {
return clicks -> users -> countries -> clicks
// KStream-KTable join: clicks must be co-partitioned with users (same key, same partitions).
.leftJoin(users, (click, user) ->
user == null ? click : click.withUser(user))
// KStream-GlobalKTable join: no co-partitioning required;
// we look up country by user.countryCode (any field, not the stream key).
.leftJoin(countries,
(clickKey, click) -> click.user() == null ? null : click.user().countryCode(),
(click, country) -> click.withCountry(country))
.filter((k, v) -> v.country() != null)
// Re-key by country code so the aggregation is co-partitioned by country.
.selectKey((k, v) -> v.country().code())
.groupByKey(Grouped.with(Serdes.String(), clickSerde()))
.windowedBy(TimeWindows
.ofSizeAndGrace(Duration.ofMinutes(5), Duration.ofMinutes(2)))
.count(Materialized.as("clicks-per-country-5m"))
.toStream();
}
private Serde<Click> clickSerde() { /* JSON / Avro serde */ }
}
spring:
cloud:
stream:
function:
definition: enrichAndCount
bindings:
enrichAndCount-in-0: { destination: clicks }
enrichAndCount-in-1: { destination: users }
enrichAndCount-in-2: { destination: countries }
enrichAndCount-out-0: { destination: clicks-per-country-5m }
kafka:
streams:
binder:
applicationId: click-analytics
configuration:
processing.guarantee: exactly_once_v2
num.stream.threads: 4
state.dir: /var/lib/streams
bindings:
enrichAndCount-in-1: { consumer: { materializedAs: users-store } }
enrichAndCount-in-2: { consumer: { materializedAs: countries-store } }
The Materialized.as("clicks-per-country-5m") call gives the windowed aggregation a queryable state store name that you can query interactively from the Spring app via KafkaStreams.store(...) for low-latency dashboards backed by the local RocksDB store.
Common Mistakes & Pitfalls
Using KStream for what's really a stateful per-key view and ending up reimplementing aggregation, or using GlobalKTable for large datasets where memory blows up on every instance
Check your understanding
Answer these without looking back at the article.
- Why do KStream-KTable joins need co-partitioning but GlobalKTable joins don't?
- When would you use a session window instead of a tumbling window?
- How does Kafka Streams achieve `exactly_once_v2` semantics?
- What is `num.standby.replicas` and why does it matter for state-heavy topologies?
Related interview prep categories
2Want a guided curriculum order?
These modules are also sequenced in our flagship backend interview track — open it when you prefer an ordered path instead of browsing by topic.