Spark Structured Streaming lets you write a stream-processing job as if it were a batch query over a table that never stops growing. This page explains the model, how micro-batches and checkpoints give fault tolerance, triggers and output modes, and a complete PySpark example.
Spark Structured Streaming is Apache Spark's stream-processing engine, introduced in Spark 2.0 and marked production-ready in 2.2. Its central idea is that a stream is just a table that keeps growing. You write an ordinary DataFrame or SQL query against it, and Spark figures out how to run that query incrementally as new data arrives, maintaining whatever state is needed between runs.
It replaced the older DStream API (Spark Streaming), which exposed raw RDD micro-batches and left event-time handling and exactly-once delivery largely to the developer. Structured Streaming runs on the same Catalyst optimiser and Tungsten execution engine as batch Spark, so the same code can often run in batch or streaming mode by switching read to readStream and write to writeStream.
Many teams already use Spark for batch ETL. Structured Streaming lets them add low-latency pipelines (Kafka to Delta Lake, CDC ingestion, near-real-time aggregates) without learning a second engine and without maintaining two code paths. It provides end-to-end exactly-once guarantees for replayable sources and idempotent or transactional sinks, event-time windows with watermarks, stateful operations, and stream-static and stream-stream joins. It is also the engine behind Databricks Auto Loader and Delta Live Tables. Its latency floor (typically hundreds of milliseconds to seconds in micro-batch mode) is the main reason teams choose Apache Flink instead for very low-latency work.
By default the engine runs a series of small batch jobs. For each trigger it asks every source for its latest available offset (Kafka partition offsets, new files in a directory), writes the planned offset range to a write-ahead log in the checkpoint location, runs an incremental job over exactly that range, writes the output to the sink, and records a commit. Stateful operators (aggregations, deduplication, joins, flatMapGroupsWithState) keep per-key state in a versioned state store, by default backed by memory plus checkpoint files, optionally RocksDB.
Because offsets are logged before processing and sources can replay, a crash at any point leads to the same batch being recomputed with the same data. If the sink is idempotent or transactional (Delta Lake, or foreachBatch code that upserts by key), the result is exactly-once. See Exactly-Once Semantics.
The trigger controls when batches run: the default (as fast as possible), processingTime="30 seconds" (fixed interval), availableNow=True (process everything currently available in several batches, then stop; good for scheduled incremental jobs; it replaces the older once=True), and continuous="1 second" (experimental low-latency mode with a limited set of supported operations).
Append writes only new result rows that will never change; with aggregations this requires a watermark so Spark knows when a window is final. Update writes only rows that changed in this trigger. Complete rewrites the entire result table each time, which is only practical for small aggregates.
Built-in sources include Kafka, files (Parquet, JSON, CSV, ORC and others in a directory), Delta tables, and a rate source for testing. Sinks include Kafka, files, Delta, console and memory for debugging, and foreach/foreachBatch for arbitrary output such as JDBC upserts.
This PySpark job reads JSON click events from Kafka, counts clicks per page in 5-minute event-time windows with a 10-minute watermark, and writes finalised windows to Parquet. Start it with the Kafka connector package matching your Spark version, for example spark-submit --packages org.apache.spark:spark-sql-kafka-0-10_2.12:3.5.1 clicks.py.
from pyspark.sql import SparkSession, functions as F, types as T
spark = SparkSession.builder.appName("clicks").getOrCreate()
schema = T.StructType([
T.StructField("user_id", T.StringType()),
T.StructField("page", T.StringType()),
T.StructField("ts", T.TimestampType()),
])
raw = (spark.readStream.format("kafka")
.option("kafka.bootstrap.servers", "localhost:9092")
.option("subscribe", "clicks")
.option("startingOffsets", "latest")
.load())
events = (raw.select(F.from_json(F.col("value").cast("string"), schema).alias("e"))
.select("e.*"))
counts = (events
.withWatermark("ts", "10 minutes") # tolerate 10 min lateness
.groupBy(F.window("ts", "5 minutes"), "page")
.count())
query = (counts.writeStream
.outputMode("append") # emit each window once, when final
.format("parquet")
.option("path", "/data/clicks_per_page")
.option("checkpointLocation", "/chk/clicks_per_page")
.trigger(processingTime="1 minute")
.start())
query.awaitTermination()Walk through what happens to one window. Suppose the latest event time Spark has seen is 12:17. The watermark is that maximum minus the allowed lateness, 12:07. The window 12:00 to 12:05 ends before the watermark, so no event that Spark is still willing to accept can change it: in append mode it is written to Parquet at the next trigger and its state is dropped. The window 12:05 to 12:10 is still open; a late event stamped 12:08 arriving now is counted. An event stamped 12:03 arriving now is older than the watermark and may be dropped. The watermark therefore trades completeness (how late data can be and still count) against latency (how long you wait before a window is emitted) and state size (how many open windows Spark keeps in memory). Choose the lateness from measured delays in your sources, not by guessing.
To test without Kafka, replace the source with spark.readStream.format("rate").option("rowsPerSecond", 100).load(), which emits timestamp and value columns, and the sink with format("console"). query.lastProgress returns a JSON dictionary with input rows per second, batch duration and state size, which is what you should monitor in production.
| Feature | Spark Structured Streaming | Apache Flink | Kafka Streams |
|---|---|---|---|
| Execution model | Micro-batch (continuous mode experimental) | Record-at-a-time streaming | Record-at-a-time, library in your app |
| Typical latency | Sub-second to seconds | Milliseconds | Milliseconds |
| API | DataFrame / SQL, same as batch | DataStream, Table API, SQL | Java/Scala DSL |
| State | State store (HDFS-backed or RocksDB) | RocksDB / heap with incremental checkpoints | RocksDB + Kafka changelog topics |
| Deployment | Spark cluster, Databricks, EMR | Flink cluster / Kubernetes | Any JVM process |
| Best when | You already run Spark; lakehouse ETL | Low latency, complex event time logic | Kafka-in, Kafka-out microservices |
availableNow on a schedule.foreachBatch. A batch can be replayed after failure; use batchId or key-based upserts so replays do no harm.