Join Streaming and Batch Data
Learn how to unify streaming and batch data in Databricks. This lesson shows you how to join a streaming DataFrame with a static batch table, handle late data, and choose the right join strategy for your pipeline.
Focus: join streaming and batch data sources
Imagine this: you're building a real-time dashboard, and you need to enrich every incoming click event with customer profile data that lives in a slowly-changing batch table. Naively, you might try to join a streaming DataFrame to a static DataFrame with a standard Spark join — and then watch your job crash with a cryptic org.apache.spark.sql.AnalysisException. The pain is real: streaming data is infinite, batch data is finite, and Spark's default joins assume both sides fit in memory at plan time. This lesson solves that exact problem. You'll learn how to safely join streaming and batch data sources in Databricks using Structured Streaming, understand when to use a stream-static join versus a stream-stream join, and walk away with a working pattern you can drop into your next pipeline.
The Problem: Why Joining Streams to Static Data Breaks
If you've tried to join a streaming DataFrame with a batch DataFrame in Spark, you've likely hit an error like this:
org.apache.spark.sql.AnalysisException: Queries with streaming sources must be executed with writeStream.start();;
Or worse, a silent failure where your stream just doesn't emit any rows. The root cause is that Structured Streaming treats the streaming side as an unbounded table — new data arrives continuously. Meanwhile, the batch side (a static DataFrame) is bounded — it has a fixed set of rows at the time you read it. Spark's core optimizer doesn't know how to reconcile these two worlds without explicit guidance.
In practice, this affects every data engineer who needs to:
- Enrich real-time events with reference data (dimension tables).
- Combine live sensor feeds with historical batch snapshots.
- Merge streaming clicks with nightly-updated user profiles.
The good news? Spark provides first-class support for this — you just need to know the right patterns. This lesson walks you through them step by step, so you can stop fighting errors and start shipping reliable pipelines.
Core Concept / Mental Model: The Two-World View
Think of your data as two worlds:
- The Streaming World — an endless river of events, represented as a DataFrame with
isStreaming = true. Examples: IoT sensor readings, clickstreams, transaction logs. - The Batch World — a static snapshot, represented as a DataFrame with
isStreaming = false. Examples: lookup tables, dimension tables, historical aggregates.
A stream-static join (also called stream-batch join) lets you combine these two worlds. The mental model: you read the batch table once (or periodically refresh it), and then every micro-batch of the streaming side joins against that snapshot. The batch side acts like a lookup table — it's loaded into memory/executors and reused across micro-batches.
Key definitions to keep straight:
- Streaming DataFrame — unbounded, continuous, appended to over time.
- Static DataFrame — bounded, finite, either read once or refreshed.
- Micro-batch — the unit of execution in Structured Streaming; a small chunk of stream data processed in a trigger interval.
- Watermark — a threshold that defines how long you'll wait for late-arriving events (critical for stream-stream joins, optional for stream-static).
The power of the stream-static join is its simplicity: you don't need any watermarking or state management for the batch side. The batch side is just a static lookup, so Spark can optimize it like a regular join. But there's a catch: the batch DataFrame is read once when the streaming query starts. If your batch data changes, you need to rebuild the query or use a broadcast join with periodic refresh.
How It Works Step by Step
Structured Streaming handles stream-static joins in a straightforward, predictable way. Here's the cause-and-effect chain:
- Read the streaming source — Define a DataFrame with
.readStream. This creates the unbounded input. - Read the batch source — Define a static DataFrame with
.reador.table. This is the bounded side. - Perform the join — Use
.join()exactly as you would with two batch DataFrames. Spark's Catalyst optimizer recognizes that one side is streaming and the other is static. - Set the join type — You can use
inner,left_outer,right_outer, orfull_outer. The default is inner. Most stream-static joins useleft_outerto keep all stream events, even when there's no match. - Write the output — Use
.writeStreamto sink the joined result.
Spark applies the following rules automatically:
- The static side is treated as a lookup table and is loaded once per streaming job start.
- The streaming side drives the output; each micro-batch joins against the static snapshot.
- Join order matters — Spark requires the streaming side to be on the left side of the join. If you put the static side on the left, you'll get an error.
Pro tip: Always put the streaming DataFrame on the left of the join. If you get an
AnalysisExceptionsaying "Streaming side must be on the left", just flip the order.
Hands-On Walkthrough
Let's put this into practice. We'll simulate a pipeline that joins live click events with a static user profile table.
1. Set up the synthetic streaming source
In a Databricks notebook, start by creating a mock stream using rate source (or use your own Kafka/Auto Loader source):
from pyspark.sql import functions as F
from pyspark.sql.types import StructType, StructField, IntegerType, StringType, TimestampType
# Define schema for the streaming events
schema = StructType([
StructField("event_time", TimestampType()),
StructField("user_id", IntegerType()),
StructField("product_id", IntegerType()),
StructField("action", StringType())
])
# Simulate a stream: generate rows every second
streaming_df = (
spark.readStream
.format("rate")
.option("rowsPerSecond", 5)
.load()
.selectExpr(
"timestamp as event_time",
"CAST(value % 100 AS INT) as user_id",
"CAST(value % 50 AS INT) as product_id",
"'view' as action"
)
)
streaming_df.printSchema()
Now, create a static reference table with user profiles:
# Static user dimensions (batch data)
user_profiles_df = spark.createDataFrame([
(0, "Alice", "US"),
(1, "Bob", "UK"),
# ... more rows
], ["user_id", "user_name", "country"])
user_profiles_df.show(5)
2. Perform the stream-static join
Join the streaming events to the static profiles, enrich with user names, and output to memory (for demo) — you can later write to Delta Lake:
# Enrich events with user names
enriched_df = (
streaming_df
.join(user_profiles_df, on="user_id", how="left_outer")
.select(
"event_time",
"user_id",
"product_id",
"action",
"user_name",
"country"
)
)
# Write to console (or memory) for debugging
query = (
enriched_df
.writeStream
.outputMode("append")
.format("console")
.option("truncate", "false")
.trigger(processingTime="5 seconds")
.start()
)
query.awaitTermination(timeout=30000) # Stop after 30s for demo
Expected output (simplified):
-------------------------------------------
Batch: 3
-------------------------------------------
+---------------------+-------+----------+------+---------+-------+
|event_time |user_id|product_id|action|user_name|country|
+---------------------+-------+----------+------+---------+-------+
|2025-02-11 10:00:03| 7| 12| view| null| null|
|2025-02-11 10:00:04| 42| 18| view| null| null|
|2025-02-11 10:00:05| 13| 5| view| null| null|
+---------------------+-------+----------+------+---------+-------+
Notice the user_name nulls — that's because our mock user_id values (0–99) don't match the static table. For a real join, you'd have matching keys.
3. Use a broadcast join for large lookup tables
If your reference table is small (e.g., a few million rows), use a broadcast join to avoid shuffling the static side on every micro-batch:
from pyspark.sql import functions as F
# Hint a broadcast join
enriched_df_bc = (
streaming_df
.join(F.broadcast(user_profiles_df), on="user_id", how="left_outer")
.select("event_time", "user_id", "product_id", "action", "user_name", "country")
)
query_bc = (
enriched_df_bc
.writeStream
.outputMode("append")
.format("console")
.start()
)
query_bc.awaitTermination(20000)
4. Periodic refresh with a static snapshot (advanced)
If your batch data changes, you can rebuild the static DataFrame on a schedule using foreachBatch:
def process_batch(batch_df, batch_id):
# Re-read the latest static lookup table
fresh_profiles = spark.table("default.user_profiles")
# Join this batch with the fresh static table
enriched = batch_df.join(fresh_profiles, on="user_id", how="left_outer")
enriched.write.format("delta").mode("append").save("/mnt/delta/enriched")
# Read your real streaming source (e.g., Auto Loader)
stream = (
spark.readStream
.format("cloudFiles")
.option("cloudFiles.format", "json")
.load("/mnt/events")
)
stream.writeStream.foreachBatch(process_batch).start().awaitTermination()
In this pattern, every micro-batch re-reads the latest batch table, so you get fresh lookups — at the cost of extra I/O.
Compare Options / When to Choose What
Not all stream-batch joins are the same. Here's a comparison of the main approaches:
| Approach | Description | Best for | Trade-offs |
|---|---|---|---|
| Static DataFrame read once | Read batch table at start; join every micro-batch | Reference data that rarely changes (e.g., product catalog) | Simple, fast; but stale after changes |
| Static DataFrame with refresh | Re-read batch table on a schedule (e.g., daily) | Dimensions updated daily | Freshness vs. I/O cost |
| Broadcast join hint | Broadcast static side to all executors | Small lookup tables (<100 MB) | Speeds up join; memory overhead |
| foreachBatch + re-read | Join per micro-batch with fresh batch read | Critical freshness (e.g., user profile changes in real-time) | High overhead; not for high-frequency streams |
| Stream-stream join | Both sides streaming with watermarking | Joining two events (e.g., clicks and impressions) | More complex; needs state management |
Rule of thumb: If the batch side is static and small, use a broadcast stream-static join. If it changes slowly, use a periodic refresh. If you need sub-second freshness for both sides, you're in the stream-stream territory (covered in the next section briefly).
Troubleshooting & Edge Cases
Here are the most common errors you'll hit and how to fix them:
1. Streaming side must be on the left
Symptom: You get an AnalysisException when your static DataFrame is on the left.
Fix: Flip the join order: streaming_df.join(static_df, ...).
2. Null values after a left outer join
Symptom: Your enriched output has null for all the batch columns.
Cause: The join key doesn't match any row in the static table, OR your static table is empty.
Fix: Verify your keys share the same type (e.g., both IntegerType). Also check that the static DataFrame actually has rows by doing .count().
3. Stream never produces output
Symptom: Your console sink prints nothing after several triggers.
Cause: Your outputMode might be update, but you're using an append-only query with aggregations. Or your trigger interval is too long.
Fix: Start with outputMode("append") for simple joins. For aggregates, use update or complete.
4. Static DataFrame becomes stale
Symptom: You see old values for hours even though the batch table is updated.
Cause: The static DataFrame is read once at query start.
Fix: Use foreachBatch to re-read periodically, or restart the stream nightly.
5. Performance issues with high-frequency streams
Symptom: Join slows down as stream volume grows.
Cause: Large shuffle on the streaming side if the static table isn't broadcast.
Fix: Add a broadcast hint, or filter the static table to only necessary columns.
What You Learned & What's Next
In this lesson, you mastered the core pattern of join streaming and batch data sources. You can now explain why a naive join fails, apply the stream-static join pattern in Databricks, choose between static reads, broadcast joins, and foreachBatch for freshness, and troubleshoot common errors like the "left side" constraint. You also connected the dots to your overall pipeline design.
Where to go next? This is the perfect foundation for the next lesson in the track: Watermarking and Handling Late Data. Once you're comfortable with stream-static joins, you'll extend your skills to stream-stream joins, where you must manage state with watermarks to avoid unbounded state growth. This is the natural next step to build robust, production-grade streaming pipelines.
Practice recap
Now it's your turn: in a Databricks notebook, create a mock streaming source (e.g., rate) and join it with a small static table of your choice. Test both a regular join and a broadcast join, and observe the output in the console. Then, modify the static DataFrame and notice that the stream doesn't pick up changes unless you restart it. Finally, try a foreachBatch version that re-reads the static table to see the difference.
Common mistakes
- Putting the static DataFrame on the left side of the join — Spark throws an
AnalysisException. Always put the streaming DataFrame on the left. - Assuming the static DataFrame is refreshed automatically — it's read once at query start. Use
foreachBatchor restart the stream to pick up changes. - Using
outputMode("complete")with a simple join — this fails because complete mode only works with aggregations. Useappendorupdate. - Forgetting to broadcast a small lookup table — causes excessive shuffling and poor performance on high-volume streams.
Variations
- Use
foreachBatchto re-read a refreshed batch table on every micro-batch for near-real-time freshness. - Use a
stream-stream joinwith watermarks when both sides are streaming events (e.g., clicks and impressions). - Use a
broadcast join hintto speed up joins to tiny reference tables.
Real-world use cases
- Enrich real-time clickstream events with a static user profile table to power a personalized recommendation dashboard.
- Join live IoT sensor readings with a batch dimension table of device metadata for operational monitoring.
- Merge incoming transaction logs with a nightly-refreshed currency lookup table to produce real-time financial reports.
Key takeaways
- A stream-static join combines an unbounded streaming DataFrame with a bounded static DataFrame — no watermarking needed for the static side.
- The streaming DataFrame must be on the left of the join or Spark will throw an
AnalysisException. - The static side is read once at query start, so use
foreachBatchor periodic restarts for fresher reference data. - Broadcast the static side to avoid shuffles when the lookup table is small.
- Choose between static read, broadcast join, or
foreachBatchbased on data size and freshness requirements. - Stream-stream joins (with watermarks) are the next step for joining two streaming sources.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.