Search Tech Journey

Find topics, journeys and posts

6-month learning plan46 / 130
back to blog
data engineeringintermediate 55m read

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.

🗄️Data EngineeringM05 · Data Engineering· Session 046 of 130 90 min

🎯 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.

You will be able to
  • 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

Mail vs SMS
🌍 Real world

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.

💻 Code world

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

Ask these before you commit to a stack
  • 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

  1. 2004
    Google MapReduce paper
    Batch on cheap commodity clusters. Hadoop follows in 2006.
  2. 2011
    Kafka open-sourced at LinkedIn
    Distributed log becomes the streaming substrate.
  3. 2013
    Lambda Architecture · Nathan Marz
    Run batch + streaming in parallel to reconcile. Powerful but expensive.
  4. 2015
    Kappa Architecture · Jay Kreps
    ‘Just stream everything and replay.’ Marketing wins the decade.
  5. 2016
    Apache Beam / Dataflow model
    Tyler Akidau's paper unifies batch + streaming as one API.
  6. 2020
    Cloud warehouses hit \<1min latency
    Snowflake Snowpipe + BigQuery streaming inserts blur the line further.
  7. 2023
    Streaming 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

Bounded

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
Unbounded

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

1expensive
Lambda (2013)

Two pipelines: batch for correctness + speed layer for recency. Merge in the serving layer. Correct but 2× code.

2hipster
Kappa (2014)

One pipeline: everything is a stream. Reprocess by replaying Kafka. Elegant but hides operational pain.

3wins
Modern Lakehouse (2023)

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

Late data
An event with timestamp 10:03 arrives at 10:15. Which window does it belong to? Watermarks + allowed lateness handle this in Flink/Spark Structured Streaming.
semantics
Out-of-order events
Events A (t=10:00) and B (t=10:05) arrive as B then A due to network re-ordering. Every stateful operator must sort by event-time, not arrival-time.
semantics
Backpressure
Producer faster than consumer. Kafka absorbs the shock with disk; Flink/Spark propagate backpressure upstream. Ignore and you OOM.
runtime
Exactly-once is a promise, not a fact
‘Exactly-once’ usually means ‘effectively-once given a sink that supports idempotent writes or 2PC’. Read the fine print of your engine.
correctness

Common misconception
✗ What most people think

"Streaming is just batch with a very small batch size. Shrink the window enough and batch becomes streaming."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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.
From first principles
Start with the question

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?

  1. 1
    To 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
  2. 2
    But 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
  3. 3
    So 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
  4. 4
    On 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
  5. 5
    Therefore 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

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.

Mental modelClosed ledger vs. open tab

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.
🔔 Fires when you see

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".

The tradeoff

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?

Batch, run more frequently
+ you gain keeps full replayability and deterministic reruns, reuses the existing code and tests, and no new failure modes; hourly is a large latency improvement over nightly for almost no engineering cost
− you pay latency floors out at the job duration plus the schedule interval, and each run re-reads more than it strictly needs unless it is incremental
pick when the actual requirement is hours, not seconds — which, when you ask what decision the number drives, it very often is
Micro-batch (Structured Streaming, small triggers)
+ you gain seconds-to-minutes latency with mostly batch-like semantics and largely the same API; checkpointing and exactly-once sinks come built in
− you pay you now own state stores, checkpoints and watermarks; per-trigger overhead limits how small the interval can usefully go; recovery and backfill are meaningfully harder than batch
pick when latency needs to be under a few minutes and the aggregation is windowed — the pragmatic middle, and the right default when batch is genuinely too slow
True event-at-a-time streaming (Flink-style)
+ you gain sub-second latency, sophisticated event-time handling, and genuine per-event processing for things like alerting and fraud detection where per-event action is the point
− you pay a separate system to operate and staff, the hardest failure and upgrade story of the three, and state management becomes a first-class operational concern
pick when an action is triggered per event and the value of that action decays in seconds — fraud blocking, alerting, real-time personalisation
What a senior engineer actually does

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

Batch · sorted deterministic output
Bounded input means you can wait until end-of-file, sort keys, and emit once. That's the entire simplicity story of batch.
bounded
Streaming · watermark = max(event_time)
Watermark is a moving line: ‘I believe all events with ts &lt; watermark have arrived’. Every stream engine has one, mostly hidden.
watermark
Streaming · allowed lateness
Extra grace period after the window closes. Events later than that are dropped (or side-outputted). This is a per-pipeline knob you MUST tune.
lateness
Streaming · window emission
A window emits only after watermark passes (window_end + allowed_lateness). Emit too early = wrong count; emit too late = stale downstream.
windowing
Streaming · state store
The counts dict IS the state store. In production (Flink/Spark), this lives in RocksDB with checkpoints to blob storage.
state
Try itFeel the operational cost of streaming

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.

💡 Hint · Kill and restart stream_demo.py mid-run. Notice: all state is lost, no windows recover. Now you understand why real streaming engines exist — this is the checkpoint problem.

(d) Production reality · 15 min

War story LinkedIn· 2018documented in ‘Apache Samza at LinkedIn — Taking Stream Processing to the Next Level’
🔥 What broke

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.

🧯 The fix

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’.

🎓 Lesson to steal
Even the world's most streaming-native company runs batch for analytics. If they can't justify streaming everywhere, you probably can't either.
Post-mortem
War story Netflix· 2019Keystone pipeline: 3+ trillion events/day
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
Exactly-once + low latency + high throughput is a pick-two. Split pipelines by SLO instead of trying to make one pipeline serve every need.
Post-mortem
War story Uberdescribed in ‘Real-Time Exactly-Once Event Processing’ blog
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
‘exactly-once’ in Kafka requires transactional producer + read-committed consumer + idempotent or transactional sink. Miss any leg → duplicates.

Where this shows up in the rest of the plan

This is the map for the next four sessions
S047 · Spark
Batch engine that also does micro-batch streaming.
S048 · Kafka
The append-only log underneath every serious streaming system.
S049 · Stream processing
Watermarks, windows, exactly-once — the deep dive.
S050 · Airflow
The batch orchestrator that schedules ‘every hour’ pipelines.
S055 · CDC
Change-data-capture converts a batch database into a streaming source.
S086 · Sagas
Distributed workflow orchestration is a streaming problem in disguise.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

  1. Batch vs streaming in 30 seconds — the latency/complexity spectrum.
  2. The three questions — before you recommend streaming.
  3. 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.