Structured Streaming: Intermediate
A streaming query is meant to run for weeks, and the processes running it will die along the way: a node gets reclaimed, a deploy restarts the driver, an executor runs out of memory. The checkpoint is how the next run learns where the last one stopped. It is a folder on durable storage, named by the checkpointLocation option on the writer, that the query updates on every micro-batch.
Inside /chk/payout_lines/, each micro-batch leaves files named by its batch number. Here is what each entry means, using batch 41 as the example:
| Entry | Written | Holds |
|---|---|---|
| metadata | Once, at the first start | The query's id, which ties every later run to this folder |
| offsets/41 | Before batch 41 runs | Where batch 41 ends in each source, plus settings frozen for the batch |
| sources/0/41 | While batch 41 is planned | The 3 new files batch 41 reads; the file source keeps this list |
| commits/41 | After the sink accepts batch 41 | A marker that batch 41 is finished |
| state/ | Every batch, stateful queries only | Values carried between batches, such as running counts |
An offset is a source's position marker. For a partitioned message log it is a record number per partition; for the file source it is an entry number in the file list it keeps under sources/. Batch 41 starts where offsets/40 ended and stops at offsets/41, so the pair pins down exactly which rows belong to the batch: here, 3 files holding 634 trip events.
One micro-batch in order. The driver records the batch's range in offsets/41 before any task starts, and writes commits/41 only after the sink's write returns.
What a restart reads
When a query starts with a checkpointLocation that already has content, Spark finds the highest batch number in offsets/ and in commits/ and compares them before reading any new data.
| Latest offsets | Latest commits | What Spark does first |
|---|---|---|
| 41 | 41 | Plans batch 42, starting where batch 41 ended |
| 41 | 40 | Runs batch 41 again over the range stored in offsets/41 |
| none | none | Treats the query as new and asks each source for its starting position |
A stream with no checkpoint
- File source: lists every file in the landing folder and processes all of them, so every trip already paid shows up in the sink again.
- Message log read from its latest position: skips every record produced while the query was down, so those trips are never paid.
- Message log read from its earliest position: replays everything the log still retains, duplicating it downstream.
Any state the query had built is gone as well. Spark protects most sinks from this: the file sink and foreachBatch refuse to start without a checkpointLocation, while the console sink, meant for debugging, gets a temporary folder that is deleted when the query stops.
- Put the checkpoint on storage that outlives the cluster, such as an object store path
- Give each query its own checkpoint folder and keep it for the life of the query
- Reuse the same checkpointLocation when you redeploy the same query
- Point 2 queries at one checkpoint folder; they overwrite each other's offsets
- Put it on the driver's local disk; a driver restarted elsewhere cannot see it
- Delete the folder to unstick a failing stream; that resets its position too
The payout query dies after offsets/41 is written and after the sink has received part of batch 41, but before commits/41 exists. It restarts with the same checkpointLocation. What does Spark run first?
| Party | Its side of the contract | What a crash does without it |
|---|---|---|
| Source | Serve the same records again when asked for an old offset range | The re-run reads different rows, or none |
| Checkpoint | Store each batch's range before it runs and a commit after it finishes | The restart cannot tell which range to redo |
| Sink | Absorb a second delivery of a batch without changing the result | The re-run adds its rows on top of the first copy |
A source that can replay
Replay has an expiry date. If cleanup deletes trip files, or the log's retention window passes, before the query restarts, the range recorded in offsets/41 no longer exists and the contract fails at its first party.
A sink that can take a replay
- Overwrite by batch: write batch 41 to a folder or partition named by its batch id, replacing whatever is already there.
- Upsert by key: match rows on trip_id, so a second copy of a trip rewrites the same row instead of adding one.
- Record the batch id: store the last batch id the target applied, and skip any batch at or below it.
Upserts and batch-id tracking need a target that supports them, such as a database table with a key. Overwriting by batch needs only a folder. foreachBatch calls your function with each batch's DataFrame and its batch id, and a re-run of batch 41 arrives with id 41 again. Overwriting by batch and recording the batch id both key on that repeated id. The upsert keys on trip_id instead, a business key that is the same in both copies of a trip. Here is write_payout_lines rewritten with the first pattern:
Transactional: Spark's file sink
A transactional sink makes a batch's rows and its batch id visible in a single atomic step: both or neither. Spark's file sink works this way. Tasks write part files into the output folder, then the driver writes _spark_metadata/41, a log entry listing exactly those files. A re-run of batch 41 finds that entry and skips the write. Spark jobs that read the folder take their file list from _spark_metadata, so part files left by a crashed attempt stay invisible to them; a tool that lists the folder directly still sees them.
| Sink | What a re-run of batch 41 does | Guarantee |
|---|---|---|
| File sink | Finds _spark_metadata/41 and skips | Exactly-once |
| foreachBatch, plain append | Appends all 634 rows again | At-least-once |
| foreachBatch, keyed on batch_id | Replaces or skips batch 41 | Exactly-once |
| Message-log sink | Publishes the records again | At-least-once |
| Console | Prints the batch again | Debugging only |
- ▸Replayable source, checkpoint, plain append: a crash duplicates a batch. At-least-once.
- ▸Socket source, checkpoint, file sink: rows read before a crash are lost, and nothing restores them.
- ▸Replayable source, checkpoint, idempotent or transactional sink: exactly-once.
A crash hits in the middle of a batch. Which pipeline still ends with each trip in the output once?
The version of write_payout_lines running that night, before the rewrite in the previous section, appended each batch to the payouts database over JDBC and stamped every row with its batch id. Here is batch 41, step by step:
| Time | What happened | Latest offsets | Latest commit | Batch 41 rows in sink |
|---|---|---|---|---|
| 02:12:00 | Batch 41 planned: 3 files, 634 trips | 41 | 40 | 0 |
| 02:12:40 | write_payout_lines appends batch 41 | 41 | 40 | 634 |
| 02:13:05 | Driver killed before commits/41 is written | 41 | 40 | 634 |
| 02:20:00 | Restart finds offsets 41, commit 40, re-runs 41 | 41 | 40 | 634 |
| 02:20:30 | Second append, then commits/41 is written | 41 | 41 | 1,268 |
Reading the duplicates
| What the duplicates look like | Cause | Part to fix |
|---|---|---|
| All in 1 batch id, 2 copies each | Crash after the write, before commits/N; the batch re-ran | Sink: make it idempotent or transactional |
| Spread across history; second copies in batch ids counting up from 0 | Checkpoint deleted or path changed; the query started over | Checkpoint: keep 1 folder per query for its whole life |
| Same event id, different source offsets or files | The producer sent the event twice | Input: deduplicate on the event id |
| Missing rows after a long outage | Retention removed the range before the restart | Source: keep data longer than the longest outage |
Duplicates the source sends
The third row sits outside Spark's contract. Exactly-once means each input record lands once, and 2 deliveries of 1 event are 2 records. Phones cause this: an app that loses signal after sending an event, before the acknowledgement arrives, sends it again with the same event id. The app_event_deliveries table has that shape: 1 row per delivery, with its own delivery_id and received_ts, and event_uuid naming the event. Run the cell to compare deliveries with distinct events.
from pyspark.sql import functions as F
deliveries = spark.table("app_event_deliveries")
result = deliveries.agg(
F.count("*").alias("deliveries"),
F.countDistinct("event_uuid").alias("distinct_events"),
)
result.show()Any gap between the 2 numbers is redelivery. deliveries.dropDuplicates(["event_uuid"]) keeps 1 row per event. distinct() would keep every copy, because 2 deliveries of an event differ in delivery_id and received_ts. On a stream, the dedupe step must remember event ids from every earlier batch to catch a copy that arrives a day late, which makes it stateful; the last section of this lesson prices that memory.
- ▸Delete the 634 extra rows once, keeping 1 row per trip_id.
- ▸Deploy the batch-id overwrite version of write_payout_lines before the next crash.
- ▸Keep /chk/payout_lines/ in place: replacing it would replay every batch, not 1.
After a restart, an analyst finds 9,400 duplicated trips. Every second copy carries a batch id between 0 and 12, while the first copies carry ids from 300 to 1,850. What happened?
| Mode | Sends to the sink after each batch | Fits |
|---|---|---|
| append | Rows added to the result table this batch, each sent once and never revised | Queries whose result rows never change once they exist |
| update | Rows added or changed this batch | Aggregations feeding a sink that can overwrite by key |
| complete | The whole result table, every batch | Small aggregations, such as totals for a few dozen cities |
One query, 3 modes
| Batch | Result table after it | update sends | complete sends |
|---|---|---|---|
| 1 | d7=2, d9=1 | d7=2, d9=1 | d7=2, d9=1 |
| 2 | d7=3, d9=1, d4=2 | d7=3, d4=2 | d7=3, d9=1, d4=2 |
| 3 | d7=3, d9=2, d4=2 | d9=2 | d7=3, d9=2, d4=2 |
Which modes a query may use
| Query shape | append | update | complete |
|---|---|---|---|
| No aggregation: filter, select, withColumn | Allowed | Allowed, same output as append | Rejected: the result table would be every row ever read |
| Aggregation with no time bound | Rejected: groups never close | Allowed | Allowed |
| orderBy with no aggregation | Rejected | Rejected | Rejected |
| Aggregation followed by orderBy | Rejected | Rejected | Allowed |
Spark checks the mode against the plan when you call start(), before any data is read, and raises an AnalysisException. A sort needs the full result in hand, which only complete mode provides, so orderBy is legal there and nowhere else. The 2 rejections you will meet most often read:
- Append output mode not supported when there are streaming aggregations on streaming DataFrames/DataSets without watermark
- Complete output mode not supported when there are no streaming aggregations on streaming DataFrames/Datasets
The sink gets a vote
A mode the query allows must also be one the sink accepts. The file sink writes immutable files and can't revise a row it has already written, so it takes append only. foreachBatch takes all 3 modes and hands your function the rows the mode selects, leaving it to your code to apply them correctly. The message-log sink also takes all 3, publishing each changed row as a new record for its consumers to reconcile.
This query writes per-driver counts to the file sink and sets no output mode. Which outputMode lets start() succeed?
| Operator | Kind | What it keeps between batches |
|---|---|---|
| select, withColumn, filter | Stateless | Nothing |
| Join to a static table | Stateless | Nothing; the static side is read again each batch |
| union of 2 streams | Stateless | Nothing |
| groupBy(...).agg(...) | Stateful | 1 running row per group |
| dropDuplicates on a key | Stateful | Every key value it has seen |
| Join of 2 streams | Stateful | Buffered rows from both sides, covered in a later lesson |
Splitting a query
Here is a per-city leaderboard of drivers, built on the same trips stream and checkpointed to /chk/leaderboard/:
Read it top down. The filter and the join to cities, a 2MB static table under the 10MB broadcast threshold, are stateless: they pipeline inside the first stage of each batch, and the broadcast adds no shuffle. The groupBy is the stateful part. It hash-shuffles the batch's rows on city_name and driver_id, and each task after the shuffle merges its slice of the batch into the running totals it holds for those keys.
- Exchange hashpartitioning(city_name, driver_id, 200): the shuffle in front of the state, with 1 state partition per shuffle partition.
- StateStoreRestore: reads each key's running totals from before this batch.
- StateStoreSave: writes the merged totals back, to be checkpointed with the batch.
from pyspark.sql import functions as Fcounts = (trips.filter(F.col("status") == "completed").groupBy("driver_id").agg(F.count("*").alias("trips")))counts.collect()
What state costs
- Work per batch grows with the batch's row count
- Holds nothing in executor memory between batches
- Restart reads offsets and commits, then carries on
- Filters and projections may change between restarts
- Work per batch grows with the batch plus the state it touches
- Holds state in executor memory and writes it under state/ each batch
- Restart reloads state from the checkpoint into the tasks that need it
- Grouping keys, aggregates and partition count are fixed by the checkpoint
The partition count is frozen
The number of state partitions is the value of spark.sql.shuffle.partitions at the query's first start, and Spark records it among the settings in each offsets file. Every later restart reuses the recorded value and ignores the one you set, because each key's state was filed under the partition its hash chose on the first day. At the default of 200, a stateful query runs 200 reducer tasks on every batch, even a batch of 12 rows, and each task loads and saves its slice of state. Adaptive query execution will not coalesce them: Spark turns it off for streaming queries and logs a warning if it's enabled. Choose the count from the expected state size before the first start.
State grows with its keys
State size follows the number of distinct keys an operator has seen, and a key stays until something removes it. The leaderboard keeps 1 row per city and driver, which levels off once every active driver has appeared. dropDuplicates(["event_uuid"]) keeps every event id it has ever seen: at 50 million events a day, a month of ids is 1.5 billion keys held across the executors and saved into the checkpoint. Bounding that growth by event time is a later lesson's subject; until then, size any stateful operator over an unbounded key before it ships.
The leaderboard has run for a month against /chk/leaderboard/. Which change, deployed against that same checkpoint, takes effect as intended?
> Picture Uber's driver incentives team shipping a live per-city leaderboard of completed trips and earnings. It reads the same trip event files as payouts, feeds a table the driver app queries every few seconds, and must survive restarts without counting a trip twice.
What a checkpoint remembers, what exactly-once requires, and which streaming queries carry state across batches.
- Category
- Spark
- Difficulty
- intermediate
- Duration
- 14 minutes
- Challenges
- 14 hands-on challenges
Topics covered: Checkpointing, Exactly-Once as a Contract, The Dup After Restart, Output Modes, Stateless vs Stateful
Lesson Sections
- Checkpointing (concepts: Structured Streaming)
That order is the design. offsets/41 is a promise about what batch 41 will read, made before the work starts; commits/41 is the receipt, written after. A crash can land between them, and Spark's restart logic exists to handle that gap. What a restart reads The middle row decides correctness. Spark never plans a fresh range for a half-finished batch: it re-reads the same 3 files, rebuilds the same 634 rows and passes them to the sink again, under the same batch id. Whether the sink is harmed by t
- Exactly-Once as a Contract (concepts: Structured Streaming)
Exactly-once describes what ends up in the sink: every input record affects the output once, even when a crash forces Spark to process a batch twice. Spark makes no promise to execute each batch a single time, and the checkpoint walk-through showed it re-running batch 41 by design. The guarantee is a contract between 3 parties, and it holds only while all 3 keep their side of it. A source that can replay A source is replayable if it can serve an old range on request. A message log that keeps rec
- The Dup After Restart (concepts: Structured Streaming)
Tuesday morning, finance reports that 634 trips from overnight were paid twice. The payout query crashed at 02:13 and came back at 02:20. Duplicates after a restart have a short list of causes, and the checkpoint together with the duplicated rows tells you which one you are looking at. Each part behaved as designed. The files were still there, the checkpoint sent Spark back to batch 41 and nowhere else, and Spark produced the same 634 rows with the same batch id. The sink appended them because a
- Output Modes (concepts: Structured Streaming)
Each micro-batch updates a result table: the answer the query would give if it ran as a batch job over every row received so far. The output mode decides which rows of that result table go to the sink after each batch. One query, 3 modes Take a running count of completed trips per driver. In batch 1, driver d7 completes 2 trips and d9 completes 1. Batch 2 adds 1 more for d7 and 2 for a new driver, d4, and batch 3 adds 1 for d9. Append has no column because Spark refuses it for this query. After
- Stateless vs Stateful (concepts: Structured Streaming)
A stateless operator computes a batch's output from that batch's rows alone. A stateful operator also needs what earlier batches contained, so Spark keeps a summary of them between batches: the query's state. Any streaming query can be cut into 2 parts: the operators that run fresh on each batch, and the ones that read and update state. Splitting a query The animation plays 1 micro-batch of the same shape as Spark runs it, trimmed to a count per driver: 9 trips in 2 input partitions, a filter th