07 · Batch Processing Basics¶
Lesson 1 introduced batch vs. streaming as a concept; this lesson gets hands-on with the two things that make batch processing actually work at scale: processing in chunks (so you never need the whole dataset in memory) and time windows (so "yesterday's data" has an unambiguous boundary) — plus the trap that boundary creates.
What actually ran
Every number below came from real pandas code run against a real CSV
file on disk.
Chunked processing¶
A file too large to fit in memory doesn't need a bigger machine — it needs to
be processed in pieces. pandas.read_csv(..., chunksize=N) returns an
iterator instead of loading everything at once:
import pandas as pd
df = pd.DataFrame({
"order_id": range(1, 23),
"amount": [round(5 + (i % 13) * 3.3, 2) for i in range(22)],
})
df.to_csv("big_orders.csv", index=False)
total = 0.0
chunk_count = 0
for chunk in pd.read_csv("big_orders.csv", chunksize=8):
chunk_count += 1
chunk_sum = chunk["amount"].sum()
total += chunk_sum
print(f"chunk {chunk_count}: {len(chunk)} rows, ids {chunk['order_id'].min()}-{chunk['order_id'].max()}, sum={chunk_sum:.2f}")
print(f"Grand total across {chunk_count} chunks: {total:.2f}")
chunk 1: 8 rows, ids 1-8, sum=132.40
chunk 2: 8 rows, ids 9-16, sum=214.90
chunk 3: 6 rows, ids 17-22, sum=138.90
Grand total across 3 chunks: 486.20
whole = pd.read_csv("big_orders.csv")["amount"].sum()
print(f"Whole-file total (sanity check): {whole:.2f}")
22 rows is small enough to load whole — the point is that the chunked total
(486.20) exactly matches the whole-file total. The pattern generalizes:
whatever you compute per chunk (a sum, a row count, a set of rejected IDs from
lesson 4's transform) must be combinable across chunks into the same
answer you'd get processing everything at once. A SUM combines trivially by
adding; an average does not — averaging three chunk-averages is wrong
unless you weight by chunk size, or better, track (sum, count) per chunk and
divide once at the end.
Windows: batch's other core idea¶
Batch jobs need a boundary: "process everything that happened between 09:00 and 10:00." That boundary is a window, and it looks simple until data doesn't arrive in the order it happened.
events = [
{"id": 1, "ts": "2026-08-29T09:58:00"},
{"id": 2, "ts": "2026-08-29T09:59:30"},
{"id": 3, "ts": "2026-08-29T10:00:15"},
{"id": 4, "ts": "2026-08-29T09:59:50"}, # arrived late
{"id": 5, "ts": "2026-08-29T10:00:45"},
]
def hour_window(ts):
return ts[:13] # 'YYYY-MM-DDTHH'
processed_before_close = events[:3] # what the pipeline has SEEN, in arrival order
for e in processed_before_close:
print(f"seen: id={e['id']} ts={e['ts']}")
closed_ids = {e["id"] for e in processed_before_close if hour_window(e["ts"]) == "2026-08-29T09"}
print(f"09:00 window closed with ids={sorted(closed_ids)} -- event 4 belongs to 09:00 but arrives AFTER close")
seen: id=1 ts=2026-08-29T09:58:00
seen: id=2 ts=2026-08-29T09:59:30
seen: id=3 ts=2026-08-29T10:00:15
09:00 window closed with ids=[1, 2] -- event 4 arrives AFTER close, belongs to 09:00, MISSED
This is late-arriving data, and it is not a hypothetical — it's one of the
most common causes of "our numbers changed after the fact" incidents in real
pipelines. Event 4 logically happened at 09:59:50, inside the 09:00 hour,
but the pipeline had already moved to processing event 3 (which arrived
earlier in wall-clock terms despite having a later timestamp — arrival
order and event-time order are not the same thing) and closed the window
before event 4 showed up. Common mitigations: a grace period (don't close
a window until N minutes after its nominal end, accepting some added latency),
a late-data reprocessing pass (re-run the window's aggregation later and
overwrite — this only works if the load step is idempotent, lesson 4), or
accepting a documented, bounded amount of undercounting for real-time
dashboards that get corrected in a nightly batch reconciliation.
Traps¶
- Averaging chunk-averages. Combine chunk results with sums and counts, divide once at the end — never average an average without weighting.
- Confusing arrival order with event-time order. A batch window boundary should almost always be based on when something happened (event time), not when your pipeline saw it (processing time) — but processing time is what naive "close the window when I've read everything so far" logic actually uses, which is exactly how late data gets dropped.
- No grace period, ever. Closing a window the instant its nominal end time passes guarantees every window quietly undercounts by however much data typically arrives late. Decide the acceptable latency/completeness tradeoff explicitly instead of getting it by accident.
- Assuming chunk size doesn't matter for correctness. It usually doesn't for simple sums — but any calculation involving ordering (running totals, "first row per group," lookback windows) can give a different, wrong answer if chunk boundaries split a logical group in two.
Cheat sheet¶
| Concept | Rule |
|---|---|
| Chunked processing | pd.read_csv(path, chunksize=N) — combine per-chunk results correctly |
| Combinable aggregation | Sum, count, min, max — combine directly |
| Non-combinable aggregation | Average, median — track (sum, count), combine at the end |
| Event time | When something actually happened |
| Processing time | When your pipeline saw it |
| Late-arriving data | Event-time falls in a window already closed |
| Grace period | Delay window close to reduce (not eliminate) lost late data |
How It Actually Works¶
Chunked processing exists because of a hard constraint: a process can only hold what fits in the memory allotted to it, but source data can be arbitrarily larger. Reading in chunks (via pd.read_csv(chunksize=...), a cursor with LIMIT/OFFSET or, better, keyset pagination, or a generator that yields batches) bounds peak memory to one chunk's size regardless of total input size, at the cost of doing the read/transform/write cycle N times instead of once. Chunk size is a real tuning knob: too small and per-chunk overhead (function calls, I/O round trips, transaction commits) dominates; too large and you're back to the original memory problem.
Windowing (tumbling, sliding, or session windows) is the batch-world's way of turning an unbounded conceptual stream into bounded units of work: a tumbling window groups events into fixed, non-overlapping time buckets (assign each row to floor(timestamp / window_size)), which is exactly the same bucketing operation a GROUP BY DATE_TRUNC(...) performs in SQL — batch windowing and streaming windowing are the same mathematical operation, just triggered by a schedule instead of by watermark-driven event arrival.
Exercise¶
Extend the events list to 30 events spread across three hourly windows, with roughly 10% arriving "late" (event-time in an earlier window than their arrival position implies — shuffle a few entries). Implement windowing two ways: (1) close each window immediately once an event from the next hour is seen, and (2) close each window only after a 3-event grace period past that point. Count how many late events each strategy captures vs. drops, and report the tradeoff in one sentence: how much extra latency did strategy 2 cost to recover how much extra completeness?