Reference architecture

Streaming analytics

The same three layers, with the clock running. Structured Streaming moves events continuously from Bronze to Gold, and the architecture's real content is everything that gets harder once you can no longer wait for the data to be complete: late arrivals, state, and exactly-once.

Streaming Databricks Structured Streaming Event time Last updated October 2026

The problem

Some decisions do not survive a nightly batch. A fraud check that runs tomorrow is a report, not a control. A machine that is about to fail does not care what the dashboard said at 06:00. A customer abandoning a basket is reachable for about four minutes.

The naive fix is to run the batch pipeline more often — every hour, then every fifteen minutes, then every five. That works for a while and then stops, because the cost grows linearly with frequency while the latency improves only down to the floor of a full recompute. At some point you have to stop recomputing the whole thing and start processing the change.

That switch brings one genuinely new problem with it. In batch, the input is complete: you process yesterday, and yesterday is over. In streaming, you never know whether the data is complete — only that nothing has arrived yet. Every hard part below follows from that one fact.

How it works

Streaming analytics: Structured Streaming through medallion layers on Databricks Event sources such as Kafka, change data capture streams and cloud files are read by Structured Streaming into an append-only Bronze table. Silver parses and deduplicates with a watermark, Gold computes windowed aggregates, and results are served to dashboards, alerts and online lookups. A checkpoint and state store underpins the whole pipeline. 1 Events 2 Read 3 Bronze 4 Silver 5 Gold 6 Serve Message bus Kafka · Event Hubs CDC stream database changes Cloud files Auto Loader readStream offsets per batch backpressure limits trigger: 10s Bronze raw payload, unparsed append-only + topic · offset · time streaming table events_raw Silver parsed · typed deduplicated enriched by join watermark 10 minutes Gold windowed aggregates upsert per window thresholds · scores tumbling 5 min window Live dashboards operational views Alerts & actions webhook · topic out Online lookups features · scoring Checkpoint + state store source offsets · aggregation state · RocksDB — in object storage, one per stream 7 End-to-end latency is the trigger interval plus the watermark plus the sink refresh. Tightening the trigger alone does not make the numbers appear any sooner.
The layers are unchanged. What is new is the band underneath: a checkpoint and state store that every stream depends on and that nothing in the layer diagram shows you.

Step by step:

  1. Events arrive on a bus, a CDC feed or as files. Each has a replayable position — an offset, a log sequence number, a filename.
  2. readStream consumes from that position in micro-batches, bounded by a rate limit so one backlog cannot overwhelm the cluster.
  3. Bronze appends the payload unparsed, with the source coordinates attached. This is both your audit trail and your replay source.
  4. Silver parses, types, deduplicates within a watermark, and enriches by joining reference data.
  5. Gold aggregates over event-time windows and writes a row per window and key.
  6. Serving is whatever consumes it: a dashboard on the Gold table, an alert evaluated per micro-batch, or a push into a low-latency store for lookups.
  7. The checkpoint underneath carries source offsets and aggregation state. It is what makes restarts correct, and it is the single most under-respected object in the system.
Streaming is a trigger, not an architecture

Bronze, Silver and Gold mean the same things here as in the batch medallion. The layering, the contracts and the governance carry over unchanged. What changes is that you can no longer wait for the input to be complete — so you must decide, explicitly, how long you are willing to wait instead.

Event time, processing time and the watermark

Two clocks exist in every streaming system: when something happened, and when you heard about it. They diverge constantly — a mobile app buffers while offline, a broker rebalances, a network partition heals and delivers an hour of backlog at once.

Aggregate by event time, always. Processing-time windows produce numbers that change depending on how your infrastructure behaved, which makes them unreproducible and, worse, quietly wrong in exactly the incidents you most want to analyse.

But event-time aggregation over an unbounded stream needs a bound, or the engine must keep every window open forever. That bound is the watermark: a declaration that you will accept data up to N behind the latest event you have seen, and discard anything later.

(events
   .withWatermark("event_time", "10 minutes")
   .groupBy(window("event_time", "5 minutes"), "device_id")
   .agg(avg("temperature").alias("avg_temp"),
        count("*").alias("readings")))
The watermark is a business decision

It is a direct trade between latency and completeness, and nobody but the business can price it. Ten minutes means a five-minute window is final fifteen minutes after it opens — and that readings older than ten minutes are dropped, silently, forever. Ask explicitly: how late is too late, and what happens to the data that misses the cut? If the answer is "we cannot lose any of it", route late events to a side table and reconcile in batch. Streaming and batch over the same Bronze is a normal design, not a failure.

The hard parts, in order of how often they bite

ProblemWhy it happensWhat to do
DuplicatesSources are at-least-once; a producer retry sends the same event twicedropDuplicatesWithinWatermark on a producer-assigned event ID
Late dataDevices buffer, networks heal, clocks driftSet the watermark deliberately; measure how much it discards
Unbounded stateHigh-cardinality keys in a stateful operator never expireWatermark every stateful operation; monitor state rows and store size
Small filesA short trigger writes a file per partition per batchLonger trigger, fewer output partitions, scheduled compaction
Stream–stream joinsBoth sides must be buffered until the join window closesBound both sides with watermarks and a time-range condition; expect the state to be large
Enrichment data changesA static dimension read at stream start never updatesRead the dimension as a stream, or restart on a schedule and accept the staleness
Schema change at the sourceA producer adds or renames a fieldKeep Bronze unparsed; parse in Silver, where a change is a code change, not an outage
Checkpoint lossSomeone deletes the path, or copies a pipeline without changing itTreat checkpoints as production state: unique per stream, backed up, never shared

Exactly-once, honestly

Structured Streaming gives you exactly-once results on a Delta sink, and it is worth being precise about how, because the guarantee does not extend past the sink.

Each micro-batch has an ID. The source offsets and the resulting Delta commit are recorded together, so on restart the engine knows which batch was committed and which must be reprocessed. Reprocessing a batch produces the same output rows, and the sink refuses to commit the same batch twice. Input may be read twice; output appears once.

This stops holding the moment the sink is something else. A webhook called from foreachBatch will fire again on a replayed batch. A row pushed to an external API will be pushed again. For anything outside the lakehouse, make the operation idempotent using the batch ID:

def publish(batch_df, batch_id):
    # Delta sinks are idempotent per (table, batch_id) via txnVersion/txnAppId.
    (batch_df.write
        .format("delta").mode("append")
        .option("txnVersion", batch_id)
        .option("txnAppId", "alerts_pipeline")
        .saveAsTable("gold.ops.alerts"))

    # External side effects are not. Key them yourself.
    for row in batch_df.where("severity = 'high'").collect():
        notifier.send(idempotency_key=f"{batch_id}:{row.device_id}", body=row.message)

(scored.writeStream
   .foreachBatch(publish)
   .option("checkpointLocation", f"{chk}/alerts")
   .trigger(processingTime="30 seconds")
   .start())

Choosing a trigger

The trigger is the main dial between latency and cost, and it is the one most often set from ambition rather than requirement.

TriggerTypical latencyComputeUse for
availableNowWhatever the schedule isOnly while runningIncremental batch — most "streaming" pipelines belong here
processingTime="5 minutes"MinutesAlways on, modestOperational dashboards, near-real-time reporting
processingTime="10 seconds"Tens of secondsAlways onMonitoring, anomaly detection, alerting
Default (as fast as possible)SecondsAlways on, higherGenuinely time-critical reactions
Low-latency modesSub-secondHighest, and operationally demandingRare. Verify the requirement is worth it before committing
Start at the top of that table

A pipeline on availableNow scheduled every fifteen minutes uses the same code as a continuous one. Switching it on later is a configuration change, not a rewrite. Starting continuous and discovering nobody looks at the dashboard before nine in the morning is a year of compute you cannot get back.

Serving the results

Where the output goes is a separate decision from how it is computed, and it is where streaming projects most often lose the latency they just paid for.

Operating it

A streaming pipeline is a long-running service, and it needs the monitoring of one rather than the monitoring of a nightly job. Four numbers tell you almost everything:

Beyond metrics, three operational habits matter:

When to use it — and when not to

Good fit

  • An action is taken automatically on the result, within minutes or seconds
  • The value of the insight decays fast — fraud, outages, abandonment, safety
  • Sensor, telemetry or clickstream data that already arrives as a stream
  • CDC replication where you want the lakehouse to track the source closely
  • Continuous arrival makes recompute wasteful even without a latency need
  • Model features that must match between training and online scoring

Poor fit

  • A human reads it in the morning — a scheduled batch is cheaper and simpler
  • The requirement is "real-time" but nobody can name the decision it drives
  • Results must be complete and reconciled, such as financial close
  • Logic changes weekly; every change is a redeploy of a live service
  • Sources are batch anyway — nightly extracts do not become fresher by streaming them
  • No team on call. Streaming pipelines fail at night, and they stay failed
The question to ask first

"What will be done differently if this number is five minutes old instead of five hours old — and who or what does it?" If the answer names an automated action or a person who is actually watching, build the stream. If it does not, you are buying continuous compute and a permanent on-call burden to make a dashboard feel modern.

Implementation checklist

  1. Write down the decision the latency serves, and the latency it actually needs. Design to that number, not to the lowest one achievable.
  2. Confirm the source is replayable and has sufficient retention to cover your worst outage.
  3. Land Bronze unparsed and append-only, with topic, partition, offset and ingest time.
  4. Give every stream its own checkpoint location, named after the stream, and never reuse one.
  5. Set a watermark on every stateful operation, and agree its value with the business.
  6. Deduplicate on a producer-assigned event ID, not on the full row.
  7. Make every external side effect idempotent on the batch ID.
  8. Set maxOffsetsPerTrigger or the equivalent before the first production backlog, not after.
  9. Start on availableNow with a schedule. Move to a continuous trigger only when the requirement demands it.
  10. Alert on end-to-end lag and state size, not only on the stream being alive.
  11. Build and test the batch backfill path over Bronze while nothing is broken.
  12. Rehearse a restart after a non-compatible code change, so the first time is not in production.
Build it with a skill

This checklist exists as a skill — streaming-pipeline makes the checkpoint, watermark and trigger decisions explicit, keys external side effects on the batch ID, and generates the batch backfill path alongside the stream.

← All reference architectures