Streaming & Incremental Ingestion
Structured Streaming, Auto Loader, and the incremental patterns behind every modern ingestion question.
The Structured Streaming mental model
Structured Streaming treats a stream as an unbounded table: new records are rows that keep getting appended, and your query is a normal DataFrame transformation that Spark re-evaluates incrementally as data arrives. You write the logic once; the engine figures out what changed since the last run and processes only that delta. Execution happens in micro-batches — small bounded jobs scheduled by a trigger — which is why a streaming query on Databricks behaves like a fast loop of batch jobs rather than record-at-a-time processing.
This is the single most useful framing for interviews, because it collapses "batch vs streaming" into one question: how often do you trigger the increment? Run it continuously and you have a low-latency stream. Run it once a day with Trigger.AvailableNow and you have an incremental batch job that never re-reads old data. Same code, same checkpoint, different schedule.
Sources and sinks on Databricks
- Sources: Delta tables (streaming reads of appends, and change feeds via CDF), Auto Loader over cloud object storage (ADLS Gen2, S3, GCS), Kafka / Event Hubs (Kafka-compatible endpoint), Kinesis, and rate/socket sources for testing. A valid streaming source must be replayable — you can re-request a given offset range.
- Sinks: Delta tables (the default and best choice — its transaction log makes writes idempotent per batch), Kafka,
forEachBatchfor arbitrary logic (MERGE, multiple tables, JDBC), and memory/console for debugging.
On Databricks the dominant pattern is files or events in, Delta out, hop by hop through Bronze → Silver → Gold, with each hop a streaming read of the previous Delta table.
Checkpointing: the part you must get exactly right
Every streaming query owns a checkpoint location — a directory the engine uses to record its progress. Inside it stores:
- Offsets (write-ahead log): which range of source data each micro-batch intends to process, written before processing starts.
- Commits: which micro-batches finished successfully.
- State store data: running aggregates, join buffers, deduplication keys for stateful queries.
- Source metadata: for Auto Loader, the ledger of which files have already been ingested.
Rules you should be able to recite cold:
- One checkpoint per query, never shared. Two queries writing to the same checkpoint corrupt each other's progress tracking.
- Never delete a checkpoint casually. Deleting it resets the query to "I have seen nothing," so the next run reprocesses everything the source still exposes — duplicates in your sink unless the write path is idempotent or you also reset the target.
- The checkpoint pins the query's identity. Some changes (different aggregation keys, changed source path semantics, some schema changes to stateful operators) invalidate it; plan a controlled reset when logic changes materially.
Exactly-once semantics
Structured Streaming into Delta gives end-to-end exactly-once from two ingredients working together:
- Replayable source: if batch 42 fails mid-flight, the engine re-requests the same offset range and reprocesses it.
- Idempotent sink: the Delta sink records the batch ID in the transaction log, so a replayed batch 42 is recognized and not written twice.
Break either ingredient and you degrade to at-least-once. The classic break: using forEachBatch to write to a non-transactional target (JDBC, REST API) without making the write idempotent yourself — e.g., keying a MERGE on a natural key, or tracking batchId in the target. Also note forEachBatch can re-execute a batch on failure, so any side effects inside it must tolerate replay.
Pitfall: "we'll just restart it from scratch." Deleting a checkpoint to "fix" a stuck stream is the streaming equivalent of rm -rf on your bookkeeping. Auto Loader's ingested-file ledger lives in that checkpoint — delete it and the next run re-ingests every file still in the source path into your Bronze table as duplicates. If you genuinely need a reset, do it deliberately: new checkpoint plus a truncated/recreated target, or a dedup-aware MERGE downstream. And never point two streams at one checkpoint to "share progress" — each query needs its own.
Triggers: how often the increment runs
- Default (micro-batch as fast as possible): start the next batch as soon as the previous one finishes. Lowest latency on a continuously running cluster, highest cost.
Trigger.ProcessingTime("5 minutes"): fixed-interval micro-batches; the standard knob for "near-real-time but not frantic."Trigger.AvailableNow: process everything available right now in one or more rate-limited micro-batches, then stop. This is "streaming as batch" — you get checkpoint-tracked incremental processing, exactly-once guarantees, and Auto Loader file tracking, but the job terminates and the cluster can shut down. Schedule it from Lakeflow Jobs hourly or nightly and you have an incremental batch pipeline with zero hand-rolled watermark tables. It replaces the olderTrigger.Once, which tried to cram everything into a single batch and fell over on large backlogs.- Continuous trigger: millisecond-latency experimental mode; know it exists, say it's rarely used, move on.
# "Streaming as batch": incremental run that drains the backlog, then exits.
(spark.readStream
.table("finance_bronze.gl_postings")
.where("doc_status = 'POSTED'")
.writeStream
.option("checkpointLocation", chk_path + "/gl_silver")
.trigger(availableNow=True)
.toTable("finance_silver.gl_postings"))
Trigger.AvailableNow is the answer to more interview questions than any other trigger. "How would you make a nightly file load incremental without tracking filenames yourself?" "How do you backfill then switch to scheduled runs?" "How do you control cost on a low-volume stream?" — all three resolve to: write it as a stream, run it with AvailableNow on a job schedule, let the checkpoint do the bookkeeping.
Output modes
- Append (default): only new rows are written. Required for plain transformations; for windowed aggregations with a watermark, a window's row is emitted once the watermark says it can no longer change.
- Update: rewrite only rows that changed since the last batch. Useful with sinks that can upsert (or
forEachBatch+ MERGE). - Complete: rewrite the entire result table every batch. Only viable for small aggregate results (e.g., a top-N leaderboard); the engine must retain all aggregate state.
Watermarks, late data, and state
Stateful operations — windowed aggregations, stream-stream joins, dropDuplicates — must buffer state, and without a bound that state grows forever. A watermark is your declaration of how late data is allowed to be: "track the max event time seen, subtract 10 minutes; anything older is too late." The engine then knows when a window is finalized, can emit it in append mode, and can purge its state.
# Tolerate 10 minutes of lateness; count events per 5-minute window.
from pyspark.sql import functions as F
(events
.withWatermark("event_time", "10 minutes")
.groupBy(F.window("event_time", "5 minutes"), "plant_id")
.agg(F.count("*").alias("event_count"))
.writeStream
.outputMode("append")
.option("checkpointLocation", chk_path + "/plant_counts")
.toTable("ops_silver.plant_event_counts"))
Trade-off to articulate: a longer watermark catches more late stragglers but holds more state and delays results in append mode; a shorter watermark is cheap and fast but silently drops anything later than the threshold. There is no free lunch — pick based on how the source actually misbehaves.
Stateful joins, at a high level: stream-static joins (stream joined to a Delta dimension table) are stateless on the stream side and just work — the static side is re-read per batch, so dimension updates are picked up. Stream-stream joins buffer both sides in state and need watermarks plus a time-range join condition so the engine knows when buffered rows can be evicted. If you're asked to design one, lead with "define watermarks on both sides and a join window, or state grows unbounded."
"Your stream emits no results for windowed aggregations, or drops some late records. Walk me through why."
Strong outline: (1) In append mode a windowed aggregate emits only when the watermark passes the window end — if the source goes quiet, the watermark stops advancing (it's driven by observed event time, not wall-clock), so the last window can sit unemitted. (2) Records older than max(event_time) − watermark delay are dropped by design; check whether the producer's clock skew or batch upload pattern exceeds the delay. (3) Mitigations: lengthen the watermark, or land everything in Bronze in append mode without aggregation and aggregate in a later hop where you can reprocess. Bonus point: state cleanup is what the watermark buys you — without it, the job eventually dies of state bloat, so "just remove the watermark" is not a fix.
Auto Loader (cloudFiles): incremental file ingestion done for you
Auto Loader is a Structured Streaming source (format("cloudFiles")) that incrementally ingests new files from cloud storage. It solves the problem every hand-rolled batch loader eventually drowns in: which files have I already processed? Auto Loader keeps that ledger in the checkpoint (a scalable key-value store), so it handles millions of files without a control table, processes each file exactly once, and recovers cleanly from failures.
# Auto Loader: incremental JSON ingestion into Bronze with schema evolution.
(spark.readStream
.format("cloudFiles")
.option("cloudFiles.format", "json")
.option("cloudFiles.schemaLocation", chk_path + "/wire_bronze/schema")
.option("cloudFiles.schemaEvolutionMode", "addNewColumns")
.load("abfss://landing@adlsacct.dfs.core.windows.net/webwire/")
.select("*", F.col("_metadata.file_path").alias("source_file"),
F.current_timestamp().alias("ingested_at"))
.writeStream
.option("checkpointLocation", chk_path + "/wire_bronze")
.trigger(availableNow=True)
.toTable("finance_bronze.webwire_events"))
What makes it more than a file watcher
- Schema inference and evolution: it samples files to infer a schema, persists it at
cloudFiles.schemaLocation, and — withaddNewColumns— when a new column appears it fails the stream once, updates the schema, and picks the new column up on restart (Lakeflow Jobs retries make this hands-off). Other modes:rescue(never change schema, shunt new fields to rescue),failOnNewColumns,none. - Rescued data column:
_rescued_datacaptures fields that didn't match the schema — type mismatches, case differences, extra attributes — as JSON instead of dropping them. You keep every byte in Bronze and triage later; that's the medallion philosophy enforced by the tool. - Directory listing vs file notification mode: default mode lists the directory to find new files (incremental listing where storage supports it) — zero extra setup, fine up to thousands-of-files-per-batch scale. File notification mode subscribes to storage events (on Azure: Event Grid + queue) so discovery is push-based — the right choice for very high file arrival rates or enormous directories where listing itself becomes the bottleneck. Trade-off: notification mode needs cloud permissions to provision the event infrastructure, and a periodic backfill listing (
cloudFiles.backfillInterval) is recommended to catch any missed events. - Why it beats hand-rolled tracking: no control table to maintain, no "last modified timestamp" race conditions, no duplicate loads when a job is rerun, exactly-once per file, and built-in observability of backlog. Everything you'd build in a metadata framework's file-audit layer, you get for one option string.
Be precise about this in interviews: production ingestion at ADM, the Port Authority, and Maersk was batch and metadata-driven — ADF pipelines parameterized from control metadata, driving PySpark loads into Delta on ADLS Gen2. You built and operated exactly the file-tracking and watermarking machinery Auto Loader replaces, which is why you can explain its value concretely: every audit column, rerun-safety check, and control-table update your framework handled by hand is what cloudFiles gives you out of the box. Auto Loader and COPY INTO themselves are platform knowledge and lab practice for you, not production claims — and saying so cleanly builds trust before you go deep on the mechanics.
COPY INTO vs Auto Loader
| Dimension | COPY INTO | Auto Loader |
|---|---|---|
| Interface | SQL command, batch semantics | Structured Streaming source (cloudFiles) |
| File tracking | Tracks loaded files internally; idempotent on rerun | Checkpoint-backed ledger; exactly-once per file |
| Scale sweet spot | Thousands of files; degrades as directories grow large | Millions to billions of files; notification mode for high arrival rates |
| Schema evolution | Limited (explicit options, more manual) | First-class: inference, evolution modes, _rescued_data |
| Latency | Scheduled batch only | Continuous, micro-batch, or AvailableNow — your choice |
| Operator profile | SQL-first teams, simple one-off or low-volume loads | Engineering teams, ongoing ingestion pipelines |
| Current guidance | Legacy-leaning; fine for ad hoc loads | Databricks' recommended default for file ingestion (and what Lakeflow Spark Declarative Pipelines use under the hood) |
Decision rule of thumb: default to Auto Loader for any recurring file ingestion; reach for COPY INTO when a SQL-only user needs a quick idempotent load of a modest directory. As of mid-2026, Databricks docs steer new ingestion toward Auto Loader or managed Lakeflow Connect connectors, with COPY INTO positioned for simpler cases.
Backfill and reprocessing patterns
- Initial backfill = the same stream: point Auto Loader at the full historical directory and run with
Trigger.AvailableNow;cloudFiles.maxFilesPerTrigger/maxBytesPerTriggerrate-limit so the backlog drains in controlled micro-batches instead of one monster batch. When it finishes, the same checkpoint carries you into incremental mode — no separate backfill code path. - Reprocess a slice: options in increasing severity — (1) re-land corrected files under new names (Auto Loader sees them as new); (2) run a separate batch job for the affected range and MERGE into the target; (3) full reset: new checkpoint + recreate target. Never surgically edit checkpoint contents.
- Logic changes downstream: keep Bronze append-only and immutable; rebuild Silver/Gold from Bronze when transformations change. This is the strongest argument for landing raw data even when stakeholders only asked for the curated layer.
- Missed-event insurance: in file notification mode, set a backfill interval so a periodic listing sweep catches anything the event pipeline dropped.
Batch vs streaming: the decision framework
| Factor | Favors batch / AvailableNow | Favors continuous streaming |
|---|---|---|
| Latency requirement | Consumers act hourly/daily (finance close, reporting) | Decisions degrade in minutes (fraud, ops monitoring, inventory) |
| Source behavior | Files/extracts arrive on a schedule; upstream is itself batch | True event firehose (Kafka/Event Hubs), continuous arrivals |
| Cost | Cluster runs minutes per day; serverless jobs spin up on demand | Always-on compute; justify it with the latency requirement |
| Complexity & ops | Simple retries, easy reprocessing, junior-friendly runbooks | Watermarks, state, checkpoint hygiene, 24/7 monitoring |
| Semantics needed | Point-in-time consistency at a cut-off (e.g., "as of close") | Freshest-possible view, eventual completeness acceptable |
The senior-engineer answer is rarely "stream everything." It's: write pipelines as incremental streaming code, then choose the trigger that matches the business latency requirement — which is usually AvailableNow on a schedule, upgraded to ProcessingTime only where someone can articulate the cost of staleness.
You have a real fit-gap story here. At ADM, part of your role is making architecture recommendations — explicitly including batch vs near-real-time trade-offs — to stakeholders for Finance R2R workloads. Sources like JDE, SAP, and HFM produce data on accounting cadences, and CFO-level consumers act on close-cycle and reporting timelines, so your recommendation weighed latency need against always-on compute cost and operational complexity, and landed on scheduled batch with tight orchestration (ADF + Lakeflow Jobs + Redwood RunMyJobs) rather than streaming for its own sake. Tell it that way: you didn't avoid streaming because you couldn't build it — you advised against paying for latency nobody would consume.
"Would you use Auto Loader or COPY INTO to ingest daily SAP extracts landing in ADLS, and why?"
Strong outline: (1) Default to Auto Loader: checkpoint-tracked exactly-once file ingestion, schema evolution with _rescued_data for the inevitable extract format drift, and it scales as file counts grow. (2) Run it with Trigger.AvailableNow on a Lakeflow Jobs schedule — batch economics, streaming bookkeeping. (3) Directory listing mode suffices at daily-extract volume; mention notification mode as the scale escape hatch. (4) COPY INTO is acceptable if the team is SQL-only and volume is small, but you'd still pick Auto Loader for an ongoing pipeline. (5) Close honestly: your production loaders were metadata-driven ADF + PySpark, so you can compare the hand-rolled approach to Auto Loader from direct experience of what the framework had to manage.
When asked anything about "incremental," name the bookkeeping explicitly. Weak answers say "we process only new data." Strong answers say where the progress is recorded: source offsets and the file ledger in the checkpoint, batch IDs in the Delta log for sink idempotency, and the schema history at the schema location. Interviewers probe exactly these seams.
Self-check before moving on
- Explain why exactly-once needs both a replayable source and an idempotent sink — and which one
forEachBatchputs at risk. - List what lives in a checkpoint and what happens operationally if you delete one under an Auto Loader stream.
- Contrast
Trigger.AvailableNowwith the default trigger and with the deprecatedTrigger.Once. - Given "events can arrive up to 30 minutes late," write the watermark line and state the cost of doubling it.
- Defend a batch recommendation to a stakeholder who asked for "real-time" — using latency need, cost, and ops complexity.