S046 · Batch vs Streaming — Mental Model & Use Cases
Not ‘which is better’ — a mental model for when latency matters more than throughput, when boundedness matters more than freshness, and why every modern platform is really both at once.
🎯 Classify any data problem as batch, near-real-time, or true streaming — and justify the choice with three questions: how fresh, how bounded, how expensive to be wrong.
Why this session exists
Every data engineer eventually gets asked "should we make this real-time?" and the correct answer is "define real-time." Batch and streaming are not competing technologies — they're two ends of a latency ↔ complexity spectrum, and 90 % of production pipelines live somewhere in the middle (micro-batch). Picking the wrong end costs 10× the engineering. This session gives you the framework to pick correctly on the first try, plus the vocabulary to reject the "let's stream everything" fad politely.
- Draw the latency/cost/complexity spectrum from daily batch to true streaming and label 5 workloads on it.
- Ask the three key questions before recommending any streaming design.
- Explain ‘bounded vs unbounded data’ and why it changes everything about semantics.
- Justify choosing micro-batch over true streaming with numbers, not opinions.
- Recognise the four classic streaming pitfalls: late data, out-of-order events, backpressure, exactly-once mirages.
Prerequisites
- S044 — NoSQL landscape (Kafka comes up as an ‘append-only log’ NoSQL).
- S045 — Data modelling (streaming still needs a model; the mistakes are worse without one).
(a) Intuition · 5 min
Consider two ways to communicate with your bank. Batch is like the monthly statement: you get one large, complete, corrected document at a predictable time. It's cheap, low-effort, and if something was wrong you can amend it before next month. But you can't react to a fraudulent charge on day 3 — you don't see it until day 30.
Streaming is like an SMS notification for every transaction. You know instantly. But now the bank has to build a system that sends millions of SMS reliably, handles retries, handles out-of-order delivery, handles you being on a plane with no signal. Expensive to build. Expensive to run.
Batch = read a bounded dataset, transform it, write the output. Airflow schedules the job at 3am; Spark reads yesterday's Parquet files; results land in the warehouse by 5am. Simple, retryable, cheap.
Streaming = an unbounded dataset arrives one event at a time forever. Every second matters. Every event may arrive out of order. Every operator must handle late data. The engine (Flink, Spark Streaming, Kafka Streams) is a small OS with checkpoints, watermarks, and state stores.
The three questions that decide it
- How fresh does the output need to be? (24h? 1h? 1min? 1s?) — anything > 5 min, batch wins.
- Is the input bounded (‘yesterday's data’) or unbounded (‘forever’)? — bounded → batch; unbounded → streaming or micro-batch.
- What is the cost of being wrong for one hour? (financial? reputational? zero?) — the smaller, the more you can lean on cheaper batch.
A quick history so you know why the world looks like this
- 2004Google MapReduce paperBatch on cheap commodity clusters. Hadoop follows in 2006.
- 2011Kafka open-sourced at LinkedInDistributed log becomes the streaming substrate.
- 2013Lambda Architecture · Nathan MarzRun batch + streaming in parallel to reconcile. Powerful but expensive.
- 2015Kappa Architecture · Jay Kreps‘Just stream everything and replay.’ Marketing wins the decade.
- 2016Apache Beam / Dataflow modelTyler Akidau's paper unifies batch + streaming as one API.
- 2020Cloud warehouses hit \<1min latencySnowflake Snowpipe + BigQuery streaming inserts blur the line further.
- 2023Streaming lakehouses (Iceberg / Hudi)Batch semantics on streaming inputs. The synthesis Marz predicted.
(b) Visual walkthrough · 15 min
The latency ↔ complexity spectrum
Bounded vs unbounded data
Has a beginning and an end.
- Example: yesterday's orders, a CSV file, a database snapshot
- Can be sorted, aggregated, joined completely
- Cheap to reason about, cheap to reprocess
- This is the batch world
Infinite stream, no end.
- Example: clickstream, IoT sensor, Kafka topic
- Must decide what ‘complete’ means (windows!)
- Late data + out-of-order is the norm, not the exception
- This is the streaming world
Lambda vs Kappa vs Modern Lakehouse
Two pipelines: batch for correctness + speed layer for recency. Merge in the serving layer. Correct but 2× code.
One pipeline: everything is a stream. Reprocess by replaying Kafka. Elegant but hides operational pain.
Iceberg/Delta/Hudi tables written by streaming, queried by batch. Same data, both semantics. Actually shipping.
The four streaming pitfalls
The problems batch pretends don't exist
"Streaming is just batch with a very small batch size. Shrink the window enough and batch becomes streaming."
The difference is not interval, it is completeness. A batch job knows its input is finite and complete before it starts; it can sort, join and aggregate over the whole thing and then exit. A stream never knows whether more data for a given time window is still coming, so every aggregate is provisional and every result must be revisable. Shrinking the batch interval gives you micro-batch — lower latency, still batch semantics — not stream semantics.
Because micro-batching genuinely does bridge much of the latency gap, and Spark Structured Streaming makes the code look nearly identical, so the boundary feels like a dial. The moment the illusion breaks is late-arriving data: in batch you simply re-run yesterday's job and the answer corrects itself. In streaming, the window may already have been emitted and the downstream consumer may already have acted on it, and now you need watermarks, allowed lateness, and a policy for what to do with the record that missed the bus.
Event time versus processing time is where the two models visibly diverge:
# Batch: the file for 09:00-10:00 is complete before the job starts.
# COUNT over that hour is final. Re-run tomorrow, same answer.
# Stream: at 10:00:01 you have SOME of the 09:00-10:00 events.
# A phone that was offline delivers its 09:15 event at 11:30.
(events
.withWatermark('event_time', '30 minutes') # 'I will wait 30 min for stragglers'
.groupBy(window('event_time', '1 hour'))
.count())
# The watermark is a BET, not a guarantee:
# too short -> late events dropped, counts are quietly low
# too long -> state retained longer, memory grows, results emitted later
# There is no setting that is both complete and immediate.Why is a watermark necessary, and why can no system ever be simultaneously complete, low-latency and bounded in memory when aggregating an unbounded stream?
- 1To emit an aggregate for a time window, the system must decide that the window is closed — that no further events belonging to it will arrive.forced by · emitting requires committing to a value; without closure the value is only provisional
- 2But events can arrive arbitrarily late — a device offline for a day, a retried request, a backfilled partition — and the system has no way to distinguish "no more events" from "none yet".forced by · absence of a message is indistinguishable from delay in an asynchronous system; this is the same impossibility that underlies failure detection
- 3So to guarantee completeness the system would have to keep every window open forever, retaining its state indefinitely.forced by · any window could still receive an event, so no state can ever be safely discarded
- 4On an unbounded stream that is unbounded memory, which is not implementable.forced by · state grows with the number of distinct windows and keys ever observed, which grows without limit
- 5Therefore the system must impose a heuristic cutoff — a watermark asserting "I believe all events up to time T have arrived" — and accept that events after the cutoff are late.forced by · bounded memory requires discarding state, and discarding state requires a decision about completeness that cannot be made from evidence
Therefore the watermark is not an implementation detail but the explicit knob on a genuine impossibility: you choose two of completeness, latency and bounded state, and the watermark is where you write down that choice.
And note what this predicts: any streaming system that claims exact correctness must either bound lateness by assumption or provide a mechanism for retraction — emitting a correction to an already-published result. Look for it in whatever system you use: allowed-lateness plus update mode, changelog streams, or upsert sinks. If the sink cannot accept a correction, then no watermark setting makes the pipeline correct, and that constraint sits in the sink, not the engine.
Batch is a closed ledger. The day ends, the book is ruled off, you total the column, and the total is final. If something was wrong, you re-open the book and recompute the whole page — cheap, deterministic, and always correct because the input never changes.
Streaming is an open tab. Orders keep arriving, someone will always tell you about one from twenty minutes ago, and yet the customer wants a running total now. So you publish a total and stand ready to amend it. Every hard problem in streaming — watermarks, exactly-once, state stores, retractions — exists because the tab never closes.
- Distinguish event time (when it happened) from processing time (when you saw it). Correctness is defined in event time; latency is measured in processing time.
- Streaming state is a database you now operate: it must be sized, checkpointed, restored after failure, and cleaned up. That operational burden is the real cost of streaming, not the code.
- "Exactly-once" means exactly-once effect, achieved through idempotent writes or transactional sinks — never exactly-once delivery, which is impossible.
- Batch is replayable and therefore debuggable: same input, same output, any time. A stream bug may be unreproducible because the input ordering that caused it is gone.
Fire this model the moment you see: a requirement stated as "real-time" without a latency number · late or out-of-order events · a dashboard number that changes after the fact · a pipeline that must be re-run after a correction · duplicate records downstream · state size growing without bound · anyone proposing to replace a nightly job with streaming to "make it faster".
A metric currently produced by a nightly batch job is wanted "in real time". Keep batch on a tighter schedule, move to micro-batch, or build true streaming?
Ask what decision the number drives and how quickly a human or system acts on it. "Real time" is almost always a proxy for "faster than nightly", and a large fraction of streaming projects are correctly served by hourly incremental batch at a fraction of the operational cost.
Reserve streaming for the cases where latency has real value per event: blocking a fraudulent transaction, paging on an anomaly, personalising a session in progress. And when you do build it, the pattern that survives contact with reality is to keep a batch path as the source of truth for correctness and reprocessing, with the stream serving the low-latency view — because the stream will eventually be wrong about something, and you will need a way to recompute it.
(c) Hands-on · 25 min
Two mirrored implementations of the same task — count events per user per minute — one batch, one streaming. Save as batch_vs_stream.py (batch) and stream_demo.py (streaming).
# batch_vs_stream.py — batch version. Reads a day of events, counts per user per minute.
# Run: python batch_vs_stream.py events_2024_08_10.jsonl
import json, sys, collections, datetime as dt
def minute_bucket(ts_iso: str) -> str:
t = dt.datetime.fromisoformat(ts_iso)
return t.replace(second=0, microsecond=0).isoformat()
def run(path: str) -> None:
counts: dict[tuple[str,str], int] = collections.Counter()
with open(path) as f:
for line in f:
e = json.loads(line)
counts[(e["user_id"], minute_bucket(e["ts"]))] += 1
# Deterministic order for reproducible tests
for (uid, minute), n in sorted(counts.items()):
print(f"{minute}\t{uid}\t{n}")
if __name__ == "__main__":
run(sys.argv[1])# stream_demo.py — streaming version using a socket source.
# Same logic, but incremental. Terminate with Ctrl-C.
# Run: python stream_demo.py
# Feed: while true; do echo "{\"user_id\":\"u1\",\"ts\":\"$(date -Iseconds)\"}" | nc -q 0 localhost 9999; sleep 0.5; done
import json, socket, collections, datetime as dt, sys, time
WINDOW_SEC = 60
ALLOWED_LATENESS_SEC = 30
def minute_bucket(ts: dt.datetime) -> dt.datetime:
return ts.replace(second=0, microsecond=0)
def run(host="0.0.0.0", port=9999) -> None:
counts: dict[tuple[str, dt.datetime], int] = collections.Counter()
watermark = dt.datetime.min.replace(tzinfo=dt.timezone.utc)
srv = socket.socket()
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((host, port))
srv.listen(1)
print(f"listening on {host}:{port} …", file=sys.stderr)
while True:
conn, _ = srv.accept()
buf = b""
while True:
chunk = conn.recv(4096)
if not chunk: break
buf += chunk
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
if not line.strip(): continue
e = json.loads(line)
ts = dt.datetime.fromisoformat(e["ts"])
key = (e["user_id"], minute_bucket(ts))
# Late data check
if ts + dt.timedelta(seconds=ALLOWED_LATENESS_SEC) < watermark:
print(f"DROP late event ts={ts.isoformat()} watermark={watermark.isoformat()}", file=sys.stderr)
continue
counts[key] += 1
# Advance watermark = max ts seen so far
if ts > watermark:
watermark = ts
# Emit any window that watermark has passed by more than ALLOWED_LATENESS
cutoff = watermark - dt.timedelta(seconds=ALLOWED_LATENESS_SEC)
to_emit = [k for k in counts if k[1] + dt.timedelta(seconds=WINDOW_SEC) < cutoff]
for k in to_emit:
print(f"EMIT window={k[1].isoformat()} user={k[0]} n={counts.pop(k)}", flush=True)
conn.close()
if __name__ == "__main__":
run()What each block does
Anatomy of the two scripts
Run stream_demo.py, feed it 30 seconds of events (see the shell one-liner in the docstring), then Ctrl-C. Restart it. Feed more events.
You'll notice all in-flight windows are gone — no state was persisted. In real Flink/Spark, checkpoints to S3/HDFS solve this every few seconds. The cost of that machinery is the actual reason streaming is expensive.
(d) Production reality · 15 min
LinkedIn went all-in on Kafka + Samza for ‘everything as a stream’. Some pipelines were textbook wins (activity feed). Others (aggregations for reporting) turned into stateful nightmares: multi-hour restart times, checkpoint corruption on infra failures, and analysts wanting SQL not Samza jobs.
They evolved to a hybrid: streaming for user-facing low-latency features, hourly batch (Spark on the same Kafka topics) for analytics. Same source of truth, two consumption modes. Their internal talks call this ‘don't fight the batch impulse’.
Netflix's Keystone streaming pipeline (Kafka → Flink → Iceberg) initially targeted <10s latency for playback quality analytics. But downstream teams built stateful business logic on top and started hitting exactly-once edge cases: Iceberg commits could occasionally double-write on Flink checkpoint failure.
Adopted Iceberg's transactional commit semantics (Nessie-style) and refactored Flink jobs to make sink writes idempotent by event-id. Latency SLO relaxed from <10s to <60s for exactly-once workloads. Latency-only jobs kept the <10s target with at-least-once.
Uber's surge-pricing pipeline needed exactly-once semantics at 100k events/sec. Naive Kafka Streams config emitted duplicates on rebalance; those duplicates propagated to the pricing model and briefly quoted the wrong surge.
Adopted Kafka transactions (idempotent producers + consumer read-committed) end-to-end, plus a downstream dedup step keyed on event_id + processing window. Now provably exactly-once for the pricing input.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
- Batch vs streaming in 30 seconds — the latency/complexity spectrum.
- The three questions — before you recommend streaming.
- Why micro-batch is usually enough — one sentence + one example.
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.