Databricks Spark Structured Streaming: Building Real-Time Pipelines That Actually Work in Production
Batch pipelines are predictable. They run, they finish, you check the results. Streaming pipelines are alive — they never stop, failures compound, and a small misconfiguration at 2am can quietly corrupt hours of data before anyone notices.
Apache Spark Structured Streaming on Databricks makes real-time pipelines significantly more manageable than raw Kafka consumer loops or custom Flink jobs. In this post I'll walk through building a production-grade streaming pipeline on Databricks — from Kafka ingest through stateful aggregations to Delta Lake sink — including the parts most tutorials skip: watermarking, late data handling, and checkpoint recovery.
Architecture Overview
Streaming Pipeline Flow
Key Concepts Before You Write Code
| Concept | What it does | Why it matters |
|---|---|---|
| Trigger | Controls micro-batch frequency | Latency vs cost tradeoff |
| Watermark | Defines how late data can arrive | Prevents state from growing unbounded |
| Checkpoint | Persists stream state and offsets | Exactly-once recovery after failure |
| Output mode | Append / Update / Complete | Controls what gets written each batch |
| Stateful aggregation | Group-by with window functions | Enables time-windowed metrics |
| foreachBatch | Custom sink logic per micro-batch | Enables Delta MERGE in streaming |
Step 1 — Read from Kafka / Azure Event Hub
Azure Event Hub exposes a Kafka-compatible endpoint so the same Spark code works for both.
from pyspark.sql import SparkSession
from pyspark.sql.functions import col, from_json, to_timestamp
from pyspark.sql.types import StructType, StructField, StringType, DoubleType, LongType
spark = SparkSession.builder.getOrCreate()
# Event schema — define this tightly, don't infer from JSON
EVENT_SCHEMA = StructType([
StructField("event_id", StringType(), False),
StructField("user_id", StringType(), False),
StructField("event_type", StringType(), True),
StructField("product_id", StringType(), True),
StructField("amount", DoubleType(), True),
StructField("event_ts", StringType(), True), # parse as string first
StructField("country", StringType(), True),
])
# Read from Kafka / Event Hub
raw_stream = (
spark.readStream
.format("kafka")
.option("kafka.bootstrap.servers", "your-eventhub.servicebus.windows.net:9093")
.option("kafka.security.protocol", "SASL_SSL")
.option("kafka.sasl.mechanism", "PLAIN")
.option("kafka.sasl.jaas.config", dbutils.secrets.get("kv-scope", "eh-sasl-config"))
.option("subscribe", "user-events")
.option("startingOffsets", "latest")
.option("maxOffsetsPerTrigger", "50000") # back-pressure control
.load()
)
# Parse the value column (bytes → JSON → typed struct)
parsed_stream = (
raw_stream
.select(from_json(col("value").cast("string"), EVENT_SCHEMA).alias("data"))
.select("data.*")
.withColumn("event_ts", to_timestamp(col("event_ts")))
.filter(col("event_id").isNotNull())
.filter(col("user_id").isNotNull())
)Step 2 — Write Raw Events to Bronze Delta
The Bronze sink is append-only. Use foreachBatch so you can add custom logic (deduplication, metadata) without losing streaming semantics.
import os
BRONZE_TABLE = "orders.bronze.events"
CHECKPOINT_BRONZE = "abfss://checkpoints@yourstorage.dfs.core.windows.net/bronze/events"
def write_bronze(batch_df, batch_id):
if batch_df.isEmpty():
return
(
batch_df
.withColumn("_batch_id", lit(batch_id))
.withColumn("_ingested_at", current_timestamp())
.write
.format("delta")
.mode("append")
.option("mergeSchema", "true")
.saveAsTable(BRONZE_TABLE)
)
print(f"Bronze batch {batch_id}: {batch_df.count()} rows written")
bronze_query = (
parsed_stream
.writeStream
.foreachBatch(write_bronze)
.option("checkpointLocation", CHECKPOINT_BRONZE)
.trigger(processingTime="30 seconds") # micro-batch every 30s
.start()
)Step 3 — Stateful Windowed Aggregations with Watermarking
This is where most streaming tutorials give up. Watermarking tells Spark how long to wait for late-arriving data before finalizing a window result. Without it, Spark holds state forever and eventually OOMs.
from pyspark.sql.functions import window, col, sum as _sum, count, avg
GOLD_TABLE = "orders.gold.event_windows"
CHECKPOINT_GOLD = "abfss://checkpoints@yourstorage.dfs.core.windows.net/gold/event_windows"
# Watermark: wait up to 10 minutes for late data
# Window: aggregate in 5-minute tumbling windows
windowed_agg = (
parsed_stream
.withWatermark("event_ts", "10 minutes") # late data tolerance
.groupBy(
window(col("event_ts"), "5 minutes"), # tumbling window
col("country"),
col("event_type"),
)
.agg(
count("event_id") .alias("event_count"),
_sum("amount") .alias("total_amount"),
avg("amount") .alias("avg_amount"),
)
.select(
col("window.start").alias("window_start"),
col("window.end") .alias("window_end"),
"country", "event_type",
"event_count", "total_amount", "avg_amount",
)
)
def write_gold(batch_df, batch_id):
if batch_df.isEmpty():
return
from delta.tables import DeltaTable
if DeltaTable.isDeltaTable(spark, f"spark_catalog.{GOLD_TABLE}"):
gold_table = DeltaTable.forName(spark, GOLD_TABLE)
# MERGE so re-processed windows update rather than duplicate
(
gold_table.alias("tgt")
.merge(
batch_df.alias("src"),
"""tgt.window_start = src.window_start
AND tgt.country = src.country
AND tgt.event_type = src.event_type"""
)
.whenMatchedUpdateAll()
.whenNotMatchedInsertAll()
.execute()
)
else:
batch_df.write.format("delta").saveAsTable(GOLD_TABLE)
gold_query = (
windowed_agg
.writeStream
.foreachBatch(write_gold)
.option("checkpointLocation", CHECKPOINT_GOLD)
.outputMode("update")
.trigger(processingTime="30 seconds")
.start()
)
# Wait for both streams (in production, use separate jobs)
spark.streams.awaitAnyTermination()Step 4 — Handling Stream Failures and Checkpoint Recovery
Checkpoints are what make Spark Structured Streaming exactly-once. Never delete them unless you want to reprocess from the beginning.
# Monitor active streams
for stream in spark.streams.active:
print(f"Stream: {stream.name}")
print(f" Status: {stream.status}")
print(f" Recent progress: {stream.recentProgress[-1] if stream.recentProgress else 'none'}")
# Graceful shutdown — let current batch finish before stopping
def graceful_shutdown():
for stream in spark.streams.active:
print(f"Stopping stream: {stream.name}")
stream.stop()
print("All streams stopped cleanly.")
# Check if checkpoint is healthy before restarting
def validate_checkpoint(checkpoint_path: str) -> bool:
try:
files = dbutils.fs.ls(checkpoint_path)
has_offsets = any(f.name == "offsets/" for f in files)
has_commits = any(f.name == "commits/" for f in files)
has_metadata = any(f.name == "metadata" for f in files)
return has_offsets and has_commits and has_metadata
except Exception:
return False
# Run before starting a stream after an outage
if validate_checkpoint(CHECKPOINT_BRONZE):
print("Checkpoint healthy — resuming from last committed offset")
else:
print("WARNING: Checkpoint missing or corrupt — will restart from 'latest'")Things to Watch in Production
Watermark too tight causes data loss; too loose causes OOM. Start with 2x your expected event latency. If events are typically 2 minutes late, set a 4-minute watermark. Monitor the eventTime.watermark metric in stream progress to tune it.
One checkpoint per stream, never share. If two streams share a checkpoint location, they'll corrupt each other's offset tracking. Always use separate ADLS paths per stream query.
maxOffsetsPerTrigger is your back-pressure valve. Without it, a traffic spike will cause a single micro-batch to process millions of records, blow your cluster memory, and fail. Set it conservatively and tune up from there.
Use Trigger.AvailableNow() for near-real-time batch. If you don't need sub-minute latency, Trigger.AvailableNow() processes all available data and stops — cheaper than a continuously running stream and easier to schedule via Databricks Workflows.
Wrapping Up
Spark Structured Streaming on Databricks gives you a solid foundation for real-time pipelines, but the production details — watermarking, checkpoint management, back-pressure, and Delta MERGE in foreachBatch — are what separate a demo from something you can actually trust at 3am. Get those right and the rest is just business logic.