8.1 Joins in Kafka Streams
Module Overview
Goal: Master joins in Kafka Streams — stream-stream, stream-table, table-table.
What You'll Learn:
- Join types (inner, left, outer)
- Stream-Stream joins (windowed)
- Stream-Table joins
- Table-Table joins
- GlobalKTable joins
- Co-partitioning requirements
- Join performance considerations
What's NOT Covered Here:
- KStream/KTable basics (covered in Module 2.1)
- Windowing details (covered in Module 5.2)
- State stores (covered in Module 6.1)
Key Insight: Joins combine data from multiple sources — understanding join semantics and windowing is crucial for correctness.
1. Join Overview
Why Joins?
Real-world scenario:
Input 1: Order events (orderId, customerId, amount)
Input 2: Customer data (customerId, name, tier)
Goal: Enrich orders with customer info
Solution: JOIN
Join Types in Kafka Streams
By data type:
├─ Stream-Stream (both event streams)
├─ Stream-Table (stream + changelog)
├─ Table-Table (both changelogs)
└─ Stream-GlobalKTable (stream + replicated table)
By semantics:
├─ Inner (match required)
├─ Left (left side always output)
└─ Outer (both sides output)
2. Co-Partitioning Requirement
What is Co-Partitioning?
Co-partitioning = same key on same partition
Required for:
├─ Stream-Stream joins
├─ Stream-Table joins
└─ Table-Table joins
NOT required for:
└─ Stream-GlobalKTable joins
Co-Partitioning Rules
Topics must have:
1. Same number of partitions
2. Same partitioning strategy
3. Same key type
Example (co-partitioned):
├─ orders: 10 partitions, key = customerId
└─ customers: 10 partitions, key = customerId
Example (NOT co-partitioned):
├─ orders: 10 partitions
└─ customers: 8 partitions ← Different count!
Auto-Repartitioning
// Kafka Streams auto-repartitions if needed
KStream<String, Order> orders = builder.stream("orders");
KTable<String, Customer> customers = builder.table("customers");
// If not co-partitioned, Kafka Streams repartitions automatically
orders.join(customers, ...);
// Repartition topic created: my-app-...-repartition
3. Stream-Stream Joins
Characteristics
Both sides: Event streams (KStream)
Behavior:
├─ Time-bounded (windowed)
├─ Events within time window joined
├─ Stateful (requires state stores)
└─ Produces new event stream (KStream)
Use case:
└─ Correlate related events in time
Example: Click and Impression Events
// Clicks: (adId, clickTime)
KStream<String, Click> clicks = builder.stream("clicks");
// Impressions: (adId, impressionTime)
KStream<String, Impression> impressions = builder.stream("impressions");
// Join clicks with impressions within 10 minutes
KStream<String, ClickImpression> joined = clicks.join(
impressions,
(click, impression) -> new ClickImpression(click, impression), // ValueJoiner
JoinWindows.of(Duration.ofMinutes(10)), // Time window
StreamJoined.with(
Serdes.String(), // Key serde
clickSerde, // Left value serde
impressionSerde // Right value serde
)
);
joined.to("click-impressions");
How Stream-Stream Joins Work
Time window: 10 minutes
Event 1: Click at 10:00 (adId=123)
└─ Stored in left state store
Event 2: Impression at 10:05 (adId=123)
└─ Looks in left state store for matching clicks [10:00-10:15]
└─ Finds click at 10:00 → emit joined record
Event 3: Click at 10:20 (adId=123)
└─ Looks in right state store for matching impressions [10:10-10:30]
└─ Finds impression at 10:05 (outside window) → no join
Join Semantics: Inner, Left, Outer
// INNER: Only matched events emitted
clicks.join(impressions, joiner, window);
// Output: Only clicks with matching impressions
// LEFT: All left events emitted (right may be null)
clicks.leftJoin(impressions, joiner, window);
// Output: All clicks, impressions optional
// OUTER: All events emitted (either side may be null)
clicks.outerJoin(impressions, joiner, window);
// Output: All clicks and impressions, matched or not
Inner Join Example
Clicks:
├─ 10:00 → click1 (adId=123)
└─ 10:10 → click2 (adId=456)
Impressions:
├─ 10:05 → imp1 (adId=123)
└─ 10:20 → imp2 (adId=789)
Join window: 10 minutes
Output (inner join):
└─ 10:05 → (click1, imp1) ← Only this matched
click2 and imp2 have no match → not emitted
Left Join Example
Same events as above
Output (left join):
├─ 10:05 → (click1, imp1) ← Matched
└─ 10:10 → (click2, null) ← No match, but click emitted
Impressions without clicks not emitted
Outer Join Example
Same events as above
Output (outer join):
├─ 10:00 → (click1, null) ← Click arrives first
├─ 10:05 → (click1, imp1) ← Match updates
├─ 10:10 → (click2, null) ← No match
└─ 10:20 → (null, imp2) ← Impression no match
All events emitted
Stream-Stream Join State
Join requires TWO state stores:
├─ Left state store (clicks)
└─ Right state store (impressions)
Each record stored for window duration
State store size:
├─ Window size × event rate
├─ Larger window = more state
└─ High event rate = more state
Example:
├─ Window: 10 minutes
├─ Rate: 1000 events/sec
└─ State: ~600,000 records per store
4. Stream-Table Joins
Characteristics
Left side: Event stream (KStream)
Right side: Changelog (KTable)
Behavior:
├─ NOT time-bounded (no window)
├─ Stream event joined with latest table value
├─ Stateful (table backed by state store)
└─ Produces new event stream (KStream)
Use case:
└─ Enrich stream events with reference data
Example: Enrich Orders with Customer Data
// Orders: event stream
KStream<String, Order> orders = builder.stream("orders");
// Customers: changelog (latest customer data)
KTable<String, Customer> customers = builder.table("customers");
// Enrich orders with customer data
KStream<String, EnrichedOrder> enriched = orders.join(
customers,
(order, customer) -> new EnrichedOrder(order, customer),
Joined.with(
Serdes.String(),
orderSerde,
customerSerde
)
);
enriched.to("enriched-orders");
How Stream-Table Joins Work
Table state (customers):
customerId=1 → Customer("Alice", "Gold")
customerId=2 → Customer("Bob", "Silver")
Stream events (orders):
1. Order(orderId=100, customerId=1, amount=50)
├─ Lookup customer=1 in table
├─ Found: Customer("Alice", "Gold")
└─ Emit: EnrichedOrder(100, "Alice", "Gold", 50)
2. Order(orderId=101, customerId=3, amount=75)
├─ Lookup customer=3 in table
├─ Not found
└─ No output (inner join)
Join Semantics: Inner vs Left
// INNER: Only orders with matching customers
orders.join(customers, joiner);
// Order without customer → dropped
// LEFT: All orders emitted (customer may be null)
orders.leftJoin(customers, joiner);
// Order without customer → emitted with null customer
Table Updates
Table updates affect FUTURE joins only
Timeline:
10:00 → Customer(1, "Alice", "Gold")
10:01 → Order(100, customerId=1) → Joins with Gold
10:02 → Customer(1, "Alice", "Platinum") ← Update
10:03 → Order(101, customerId=1) → Joins with Platinum
Previous orders (10:01) NOT retroactively updated
Stream-Table vs Stream-Stream
STREAM-TABLE:
├─ No time window
├─ Joins with LATEST table value
├─ One state store (table only)
└─ Lower state overhead
STREAM-STREAM:
├─ Time window required
├─ Joins with events in window
├─ Two state stores (both sides)
└─ Higher state overhead
Choose based on use case
5. Table-Table Joins
Characteristics
Both sides: Changelogs (KTable)
Behavior:
├─ NOT time-bounded
├─ Join latest values from both tables
├─ Stateful (both tables backed by state stores)
└─ Produces new changelog (KTable)
Use case:
└─ Combine reference data from multiple sources
Example: Join Customers with Addresses
// Customers: (customerId, name, addressId)
KTable<String, Customer> customers = builder.table("customers");
// Addresses: (addressId, street, city)
KTable<String, Address> addresses = builder.table("addresses");
// Change key to addressId for co-partitioning
KTable<String, Customer> customersByAddress = customers
.toStream()
.selectKey((k, v) -> v.addressId())
.toTable();
// Join tables
KTable<String, CustomerWithAddress> joined = customersByAddress.join(
addresses,
(customer, address) -> new CustomerWithAddress(customer, address)
);
joined.toStream().to("customers-with-addresses");
How Table-Table Joins Work
Initial state:
Customers table:
customerId=1 → Customer(name="Alice", addressId=100)
Addresses table:
addressId=100 → Address(street="Main St", city="NYC")
Joined table:
addressId=100 → CustomerWithAddress("Alice", "Main St", "NYC")
Update:
Addresses[100] → Address("Broadway", "NYC")
Joined table updated:
addressId=100 → CustomerWithAddress("Alice", "Broadway", "NYC")
Foreign Key Joins
// Customers: (customerId, name, addressId)
// Addresses: (addressId, street, city)
// Foreign key join (customerId → addressId)
KTable<String, CustomerWithAddress> joined = customers.join(
addresses,
customer -> customer.addressId(), // Foreign key extractor
(customer, address) -> new CustomerWithAddress(customer, address)
);
// Kafka Streams handles repartitioning internally
Table-Table Join Semantics
// INNER: Only records with matches in both tables
customers.join(addresses, joiner);
// LEFT: All left records (right may be null)
customers.leftJoin(addresses, joiner);
// OUTER: All records from both tables
customers.outerJoin(addresses, joiner);
6. GlobalKTable Joins
Why GlobalKTable?
Problem with regular KTable join:
├─ Requires co-partitioning
├─ Must repartition if keys don't match
└─ Overhead for small reference data
Solution: GlobalKTable
├─ ALL partitions consumed by EVERY instance
├─ No co-partitioning required
├─ Join on any field (not just key)
Example: Join with GlobalKTable
// Orders: (orderId, customerId, productId, amount)
KStream<String, Order> orders = builder.stream("orders");
// Products: small reference data (productId, name, price)
GlobalKTable<String, Product> products = builder.globalTable("products");
// Join on productId (not the key)
KStream<String, EnrichedOrder> enriched = orders.join(
products,
(orderId, order) -> order.productId(), // Key mapper (right side)
(order, product) -> new EnrichedOrder(order, product)
);
enriched.to("enriched-orders");
GlobalKTable vs KTable
KTABLE:
├─ Partitioned (each instance has subset)
├─ Requires co-partitioning for joins
├─ Lower memory (distributed)
└─ Good for large datasets
GLOBAL KTABLE:
├─ Replicated (every instance has all data)
├─ No co-partitioning required
├─ Higher memory (full copy per instance)
└─ Good for small reference data (< 1 GB)
When to Use GlobalKTable
Use GlobalKTable when:
├─ Reference data is small (< 1 GB)
├─ Join on non-key field
├─ Want to avoid repartitioning
└─ Need full dataset on every instance
Avoid GlobalKTable when:
├─ Dataset large (memory constraints)
└─ Data partitioned already
7. Join Performance Considerations
State Store Overhead
Stream-Stream join:
├─ 2 state stores (left + right)
├─ State size = window × event rate
└─ High memory/disk usage
Stream-Table join:
├─ 1 state store (table only)
├─ State size = table size
└─ Lower overhead
Table-Table join:
├─ 2 state stores (both tables)
├─ State size = both table sizes
└─ Medium overhead
Window Size Impact
Stream-Stream join window:
Small window (1 minute):
├─ Less state
├─ Lower memory
└─ Fewer matches
Large window (1 hour):
├─ More state (60× more)
├─ Higher memory
└─ More matches
Choose smallest window that meets requirements
Co-Partitioning Overhead
If not co-partitioned:
├─ Auto-repartition triggered
├─ Repartition topic created
├─ Extra network I/O
├─ Extra disk I/O
└─ Increased latency
Optimize:
└─ Pre-partition input topics correctly
Late-Arriving Data
Stream-Stream joins:
Grace period (Module 5.2):
├─ Allow late records to join
├─ Increases state retention
└─ Trade-off: completeness vs resource usage
After grace period:
└─ Late records dropped or handled separately
8. Join Code Patterns
Pattern 1: Stream-Stream Inner Join
KStream<String, PageView> pageViews = builder.stream("page-views");
KStream<String, AdClick> adClicks = builder.stream("ad-clicks");
KStream<String, ViewClick> joined = pageViews.join(
adClicks,
(view, click) -> new ViewClick(view, click),
JoinWindows.ofTimeDifferenceWithNoGrace(Duration.ofMinutes(5)),
StreamJoined.with(Serdes.String(), viewSerde, clickSerde)
);
Pattern 2: Stream-Table Left Join
KStream<String, Order> orders = builder.stream("orders");
KTable<String, Customer> customers = builder.table("customers");
KStream<String, EnrichedOrder> enriched = orders.leftJoin(
customers,
(order, customer) -> {
if (customer != null) {
return new EnrichedOrder(order, customer);
} else {
return new EnrichedOrder(order, Customer.unknown());
}
}
);
Pattern 3: Table-Table Outer Join
KTable<String, User> users = builder.table("users");
KTable<String, Profile> profiles = builder.table("profiles");
KTable<String, UserProfile> joined = users.outerJoin(
profiles,
(user, profile) -> {
return new UserProfile(
user != null ? user.name() : "Unknown",
profile != null ? profile.bio() : "No profile"
);
}
);
Pattern 4: GlobalKTable Join
KStream<String, Transaction> transactions = builder.stream("transactions");
GlobalKTable<String, Account> accounts = builder.globalTable("accounts");
KStream<String, EnrichedTransaction> enriched = transactions.join(
accounts,
(txnId, txn) -> txn.accountId(), // Extract right key
(txn, account) -> new EnrichedTransaction(txn, account)
);
Pattern 5: Multi-Way Join
KStream<String, Order> orders = builder.stream("orders");
KTable<String, Customer> customers = builder.table("customers");
KTable<String, Product> products = builder.table("products");
// First join: orders + customers
KStream<String, OrderCustomer> step1 = orders.join(customers, ...);
// Second join: (orders + customers) + products
// Must change key to productId
KStream<String, FullOrder> step2 = step1
.selectKey((k, v) -> v.productId())
.join(products, ...);
9. Join Testing
Unit Testing Joins
TopologyTestDriver testDriver = new TopologyTestDriver(topology, props);
TestInputTopic<String, Order> orderTopic =
testDriver.createInputTopic("orders", stringSerde.serializer(), orderSerde.serializer());
TestInputTopic<String, Customer> customerTopic =
testDriver.createInputTopic("customers", stringSerde.serializer(), customerSerde.serializer());
TestOutputTopic<String, EnrichedOrder> outputTopic =
testDriver.createOutputTopic("output", stringSerde.deserializer(), enrichedSerde.deserializer());
// Send customer first
customerTopic.pipeInput("1", new Customer("Alice"));
// Send order
orderTopic.pipeInput("1", new Order("100", "1", 50));
// Verify join
EnrichedOrder result = outputTopic.readValue();
assertEquals("Alice", result.customerName());
10. Common Join Pitfalls
Pitfall 1: Forgetting Co-Partitioning
// orders: 10 partitions
// customers: 8 partitions
// This will repartition (performance overhead)
orders.join(customers, ...);
// Better: Ensure same partition count upfront
Pitfall 2: Wrong Join Type
// Using inner join when left join needed
orders.join(customers, joiner);
// Orders without customers dropped silently
// Should use left join
orders.leftJoin(customers, joiner);
// All orders emitted
Pitfall 3: Stream-Stream Without Window
// ERROR: Missing window
clicks.join(impressions, joiner); // Compilation error
// Must specify window
clicks.join(impressions, joiner, JoinWindows.of(Duration.ofMinutes(10)));
Pitfall 4: GlobalKTable for Large Data
// Bad: 10 GB reference data
GlobalKTable<String, LargeData> data = builder.globalTable("large-topic");
// Every instance loads 10 GB → OOM
// Better: Use regular KTable (partitioned)
KTable<String, LargeData> data = builder.table("large-topic");
Pitfall 5: Null Handling
// Left join produces nulls
orders.leftJoin(customers, (order, customer) -> {
return order.amount() * customer.discount(); // NPE if customer is null!
});
// Correct: Check for null
orders.leftJoin(customers, (order, customer) -> {
double discount = customer != null ? customer.discount() : 0.0;
return order.amount() * discount;
});
11. Advanced Join Patterns
Versioned Joins (Kafka 3.5+)
// Use versioned KTable for temporal joins
KTable<String, Customer> customers = builder.table(
"customers",
Consumed.with(Serdes.String(), customerSerde),
Materialized.as("customers-store")
.withVersionedKeyValueStore() // Enable versioning
);
// Join with historical customer data at order time
orders.join(customers, ...);
// Joins with customer state at order timestamp
Conditional Joins
// Join only if condition met
KStream<String, Order> largeOrders = orders
.filter((k, v) -> v.amount() > 1000);
// Join large orders only
largeOrders.join(customers, ...);
Self-Joins
// Join stream with itself (different windows)
KStream<String, Event> events = builder.stream("events");
KStream<String, EventPair> selfJoin = events.join(
events,
(event1, event2) -> new EventPair(event1, event2),
JoinWindows.of(Duration.ofMinutes(5))
);
12. Join Monitoring
Metrics to Monitor
# Join state store size
kafka.streams:type=stream-state-metrics,
task-id=*,
store-id=*,
name=num-entries
# Join rate
kafka.streams:type=stream-processor-node-metrics,
task-id=*,
processor-node-id=*,
name=process-rate
# Late records dropped
kafka.streams:type=stream-processor-node-metrics,
task-id=*,
processor-node-id=*,
name=late-records-drop-rate
13. Mental Model Summary
Join Type Decision Tree
What are you joining?
├─ Two event streams (KStream + KStream)
│ └─ Use Stream-Stream join (windowed)
│
├─ Event stream + reference data (KStream + KTable/GlobalKTable)
│ ├─ Small reference data (< 1 GB)
│ │ └─ Use GlobalKTable (no co-partitioning)
│ └─ Large reference data
│ └─ Use KTable (partitioned)
│
└─ Two reference datasets (KTable + KTable)
└─ Use Table-Table join
Join Characteristics Summary
STREAM-STREAM:
├─ Time-bounded (window required)
├─ Correlate related events
├─ High state overhead (2 stores)
└─ Example: Click + Impression
STREAM-TABLE:
├─ NOT time-bounded
├─ Enrich stream with latest reference data
├─ Medium state overhead (1 store)
└─ Example: Order + Customer
TABLE-TABLE:
├─ NOT time-bounded
├─ Combine reference datasets
├─ Medium state overhead (2 stores)
└─ Example: User + Profile
STREAM-GLOBALTABLE:
├─ NOT time-bounded
├─ Join on any field (not just key)
├─ High memory (replicated to all instances)
└─ Example: Transaction + small Account lookup
14. Questions You Should Be Able to Answer
After this module, you should be able to answer:
✅ What are the three main join types in Kafka Streams?
✅ When do I need a time window for joins?
✅ What is co-partitioning and why is it required?
✅ When should I use GlobalKTable vs KTable?
✅ What's the difference between inner, left, and outer joins?
✅ How does a Stream-Table join differ from Stream-Stream?
✅ What state stores are created for each join type?
✅ How do table updates affect joins?
15. What's Next?
Now that you understand joins, the next modules will cover:
- Module 9.1: Exactly-once processing — transactions in joins
- Module 11.1: Interactive queries — querying join state stores
- Module 12.1: Monitoring — tracking join performance
Summary
You learned:
- ✅ Stream-Stream joins are time-bounded (windowed), correlate events
- ✅ Stream-Table joins enrich streams with latest reference data (not windowed)
- ✅ Table-Table joins combine reference datasets, output KTable
- ✅ GlobalKTable joins replicate data to all instances, no co-partitioning needed
- ✅ Co-partitioning required for all joins except GlobalKTable
- ✅ Join types: inner (match required), left (left always output), outer (both output)
- ✅ Window size and grace period affect state size and late data handling
- ✅ Choose smallest window and lightest join type for performance
Next up: Module 9.1 — Exactly-Once Processing — Learn how Kafka Streams achieves exactly-once semantics including in joins.