StreamingGENERALENGINE-SPECIFICSIMULATED

Tumbling Windows

Fixed size, contiguous, non-overlapping: every event lands in exactly one. The cheapest window and the only family whose results you are allowed to add together.

What actually happensHow to build itCan I trust it?

Who needs this, what one row is, and why the obvious build breaks

Every lesson starts from the consumer, because designing from the source outward is this domain's characteristic mistake.

The question

When is a fixed, non-overlapping bucket the right boundary — and what exactly does "exactly one window per event" buy me?

Who needs this

Anyone comparing periods with each other: an hourly revenue chart, a daily active count, a monthly close. They need buckets that partition time cleanly, so that adding twenty-four hourly numbers gives the daily one and nobody has to think about it.

What one row is

One row per (window_start, key). Because windows partition the timeline, each event contributes to exactly one row, which is what makes the output additive: summing rows across windows gives the same answer as aggregating the underlying events directly.

The obvious build

Truncate the timestamp to the bucket and group by it. date_trunc('hour', ts) in SQL, floor(t / size) * size in code. It is one expression and it is genuinely correct — for the assignment part.

Why it breaks

The timestamp truncated is the arrival time rather than the event time, so a buffered mobile client's morning events all land in the afternoon bucket (Event Time).

How it breaks with real data
  • The timestamp truncated is the arrival time rather than the event time, so a buffered mobile client's morning events all land in the afternoon bucket (Event Time).
  • The truncation is done in a session timezone that differs between the producer, the job and the analyst, so daily buckets are shifted by hours and the boundaries land in the middle of business days (Semantic Changes).
  • The bucket is computed correctly and emitted before the period is complete, so a consumer reads a partial hour as a real one and treats the shortfall as a business signal (Stale Dashboards).
  • A record arrives after the bucket was emitted and the job has no policy for it, so it is dropped and the hour is permanently a little low (Late Events).
  • A replay re-emits every bucket in the range, the sink appends, and each affected hour now has two rows that a downstream SUM cheerfully adds (Duplicate Rows).
SourceIngestionRawTransformationValidationStorage ModelServingConsumerObservability

What is actually happening

  • The assigner is floor(event_time ÷ size) × size, which is a total function producing exactly one window per event. Because the windows partition the timeline, no event is in two and no event is in none (Windows).
  • That partition property is what makes the output additive. Sums, counts and any other decomposable aggregate can be rolled up from smaller tumbling windows to larger ones without recomputation — hourly rows sum to daily rows correctly, by construction.
  • Not every aggregate is decomposable. Distinct counts, medians and percentiles cannot be rolled up from window results without an algebraic sketch, and adding them across windows is wrong in a way that adding sums is not (Cardinality: The Label That Took Down Monitoring).
  • Live state is one entry per key per open window. With no lateness allowance that is one window per key at a time; with an allowance it is the allowance divided by the window size, plus one (Streaming State).
  • The window's alignment is a choice with consequences. Buckets aligned to the epoch cross local midnight in some zones; an offset aligns them to a business day. Two jobs with different alignment produce non-comparable series from the same events.
  • An event exactly on a boundary belongs to the window that starts there — intervals are half-open, [start, end) — which is the convention everywhere and still worth asserting in a test, because the alternative silently double-counts boundary events.

Contiguous, disjoint, and therefore addable

SIMULATEDFrom runStream(LATE_EVENT_SCENARIO, { sizeMin: 5, allowedLatenessMin: 30 }) in src/de/sim/stream.ts; the test file asserts that the number of placements equals the number of events and that the placement set contains no duplicates, which is the partition property stated as an assertion.

The defining property is set-theoretic rather than temporal: tumbling windows partition the timeline. Every instant belongs to exactly one window, so every event belongs to exactly one window, so every event contributes to exactly one output row.

That is not a detail, it is the entire reason to prefer this family. It means the output can be treated like any other fact table: filter it, sum it, roll it up to a coarser period, join it to a dimension. None of that is safe with an overlapping window family, and the difference is invisible in the data itself (Sliding Windows).

The timeline below shows the property directly. Six events, two windows, six placements — no event appears twice and none is missing. The late arrival is placed by its event time like everything else, and the only question its lateness raises is whether the window it belongs to is still open to receive it.

Every event in exactly one window
W1 10:00–10:05W2 10:05–10:10watermark 10:08
EventHappenedArrivedLands in
a10:0110:0110:00–10:05
b10:0210:0210:00–10:05
c10:0410:0410:00–10:05
late10:0010:0710:00–10:05
Late by arrival, punctual by assignment. It joins W1 because that is where its event time puts it — provided W1 is still open (Late Events).
d10:0610:0610:05–10:10
e10:0810:0810:05–10:10

Six events, six placements, no overlap and no gap. Sum W1 and W2 and you get exactly the sum over all six events — which is what makes tumbling output safe to roll up and sliding output not.

Writing it, and the three things that go wrong in the expression

The assignment itself is a one-liner in every language, which is why the errors cluster in what surrounds it: which timestamp is truncated, in which timezone, and whether the boundary is half-open. All three produce output that looks completely normal.

The SQL below shows the streaming form and the batch form of the same window. They must agree exactly, because the batch form is the reconciliation that verifies the streaming one — if the two use different timestamp fields or different timezones, the check that was supposed to catch errors becomes a second source of them (Reconciliation).

Note the last query. Storing sum and count separately rather than an average is what makes the roll-up to a coarser period possible: averages cannot be averaged, and by the time somebody discovers that, the finer-grained data is often already gone.

The same tumbling window, three ways
1-- 1. Streaming form (Flink SQL windowing table-valued function).
2-- TUMBLE emits window_start/window_end as real columns, which is what
3-- makes the output grain self-describing.
4SELECT window_start,
5 window_end,
6 account_id,
7 SUM(amount_minor) AS amount,
8 COUNT(*) AS events
9FROM TABLE(TUMBLE(TABLE orders, DESCRIPTOR(occurred_at), INTERVAL '5' MINUTES))
10GROUP BY window_start, window_end, account_id;
11
12-- 2. Batch form -- the reconciliation. It MUST use the same timestamp
13-- field and the same timezone, or the check becomes a second bug.
14SELECT date_trunc('minute', occurred_at)
15 - make_interval(mins => extract(minute from occurred_at)::int % 5)
16 AS window_start,
17 account_id,
18 SUM(amount_minor) AS amount,
19 COUNT(*) AS events
20FROM raw_orders
21WHERE occurred_at >= timestamp '2026-08-26 10:00:00'
22 AND occurred_at < timestamp '2026-08-26 11:00:00' -- half-open, always
23GROUP BY 1, 2;
24
25-- 3. The roll-up that additivity makes safe. Note that we store sum and
26-- count and divide at the end: averaging the averages would weight
27-- every window equally regardless of how many events it held.
28SELECT date_trunc('hour', window_start) AS hour,
29 account_id,
30 SUM(amount) AS amount,
31 SUM(events) AS events,
32 SUM(amount)::numeric / NULLIF(SUM(events), 0) AS avg_amount
33FROM orders_5m
34GROUP BY 1, 2;

Three details carry the lesson: the interval is half-open in every one of them, the batch and streaming forms truncate the same field, and the roll-up divides at the end rather than averaging pre-computed averages.

Product detail — verify current documentation

The TUMBLE(TABLE ..., DESCRIPTOR(...), INTERVAL ...) windowing table-valued function is Flink SQL's form and has changed shape across major versions; older syntax used a grouped window function instead. Verify the exact form against the documentation for your version. The batch query is portable ANSI-flavoured SQL and the bucketing expression differs slightly between warehouses.

What one row means, and how the meaning slips

A tumbling window output is a fact table whose grain is (window_start, key), and it should be treated with the same care as any other fact table — which mostly means being explicit about what one row represents and what happens when someone joins it to something else (Fact Tables).

The slips below are all cases where the grain is technically preserved and the meaning is not. A duplicate row for the same window breaks additivity without changing the schema. A rolled-up average is arithmetically valid and answers a different question. A distinct count summed across windows is simply wrong and looks entirely reasonable.

The one worth writing on a wall is the last row. window_start alone does not identify a row; (window_start, key) does. A join on window_start against another windowed table with a different key set fans out, and the resulting number is larger than either input in a way that looks like growth.

The grain of a tumbling aggregate, and where it slips
StageOne row isBreaks if
Raw event streamOne thing that happened, with its own event time.The stream contains duplicates from at-least-once delivery, in which case every window sum inherits them (Deduplication).
Window state entryThe running aggregate for one key in one window.The stored value is the list of contributing records rather than the aggregate, so state grows with volume rather than with keys (Streaming State).
Emitted rowOne (window_start, key) with its aggregate as of the emission.The sink appends, so a period accumulates one row per emission and every downstream SUM counts the corrections (Upserts and Merges).
Hourly roll-up from five-minute rowsOne (hour, key), summed from twelve rows.The measure is not decomposable — an average, a median, a distinct count — in which case the roll-up is arithmetically valid and semantically wrong (Cardinality: The Label That Took Down Monitoring).
Joined to another windowed tableWhatever the join produced, which may no longer be one row per window and key.The join is on window_start alone rather than on (window_start, key), so rows fan out and every measure is multiplied by the other side's key count (Grain: What Does One Row Represent?).
Dashboard tileOne number, with the window definition now completely invisible.The most recent window is included while still incomplete, so a partial period is read as a decline (Stale Dashboards).

Every slip here preserves the schema. The type checker, the schema registry and the pipeline's success signal are all satisfied at each step, which is why the grain has to be asserted rather than assumed.

How to build it

Most important first.

  • Choose the size from the finest granularity any consumer needs, then roll up downstream. Emitting five-minute buckets and summing them to hours is cheap; splitting hourly buckets into five-minute ones is impossible (The Metrics Layer).
  • Emit window_start and window_end as explicit columns rather than a single truncated timestamp. It makes the row's grain self-describing and removes a whole class of off-by-one arguments (Grain: What Does One Row Represent?).
  • Store all times in UTC and let the presentation layer localise. A window aligned to a local business day is a legitimate requirement and should be an explicit offset, documented, not an accident of a session setting (Dataset Documentation).
  • Mark the most recent window as incomplete in the output, so a dashboard can exclude or shade it. Every partial period is read as a decline by someone (The Data Quality Dashboard).
  • Upsert on (window_start, key) at the sink. Tumbling windows re-emit on late arrivals and on every replay, and the additivity you are buying is destroyed the moment a period has two rows (Upserts and Merges).
  • Assert the roll-up identity in a test: the sum across small windows must equal a direct aggregate over the raw events for the same period. This is cheap and it catches boundary, timezone and lateness errors together (Data Tests).

What this actually promises

Naming the guarantee you do not have is worth more than naming the one you do — everything downstream inherits the weakest promise in the chain.

  • Partition: every event is assigned to exactly one window. This is the guarantee that distinguishes tumbling from sliding and everything else follows from it.
  • Additivity for decomposable aggregates: sums and counts roll up across windows correctly. Explicitly not guaranteed for distinct counts, medians or percentiles, which need sketches or a recomputation.
  • Determinism: identical assignment on every replay, given event time. A reprocessed range produces the same buckets with the same membership (Deterministic Replay: Making the Schedule Reproducible).
  • What is not guaranteed: that a window is complete when it emits; that a window will emit at all, if the watermark stalls; or that two jobs with different alignment or timezone produce comparable series (Watermarks).

Can I trust it?

A green pipeline is evidence that code ran. These four fields are the evidence that the data is right.

The check that would catch this
  • The roll-up identity is the check: sum(measure) across tumbling windows in a period must equal aggregate(measure) over raw events in that period. It catches lateness loss, boundary errors and timezone drift in one comparison (Reconciliation).
  • Add a count of rows per (window_start, key) and assert it is exactly one. More than one means the sink is appending, which is the failure that silently breaks additivity (Duplicate Rows).
  • Both miss a window definition that is uniformly wrong — an hourly bucket in the wrong timezone reconciles perfectly against a recomputation that shares the mistake. Only a comparison against a source system with its own definition finds that (Two Dashboards, Two Numbers).
Freshness
  • A tumbling result is available no earlier than the window end, plus whatever the trigger waits for. A consumer asking for a fresher number than the window size is asking for a smaller window.
  • The shape is a staircase rather than a curve: nothing changes for the duration of a window, then the number steps. That is often exactly what a consumer wants for a report, and exactly wrong for spotting a burst that starts mid-window (Sliding Windows).
  • Freshness and completeness are separable here in a way they are not elsewhere: the window can emit provisionally on a processing-time trigger and finalise when the allowance expires, which gives a fresh number and a final one from the same job (Processing Time).
When the schema or meaning changes
  • Changing the window size changes the output grain, which is a breaking change for every consumer that joins or sums the table, even though no column changed (Grain: What Does One Row Represent?).
  • Changing the alignment or timezone is worse, because the schema and the grain are both unchanged and only the boundaries move. Historical rows become non-comparable with new ones and nothing records the discontinuity (Semantic Changes).
  • The safe migration is a new dataset at the new definition running in parallel, with an overlap period where both are computed and compared, and a documented switchover (Model Layering).
How to re-run this safely
  • A replay recomputes exactly the same windows, so a corrected job's output can be compared row for row with the original. This is the cleanest recovery story of any window family (Replay from the Log).
  • A backfill can target a bounded window range — window_start between two dates — and overwrite precisely those rows, which is only possible because the windows partition the timeline (Planning a Backfill).
  • The publish must be atomic per range. Overwriting hourly rows one at a time lets a consumer observe a period that is half old and half corrected, which is a worse state than either (Atomic Publish).

What can go wrong

Failure modes
  • Arrival time truncated instead of event time, so a transport stall moves records between buckets and nothing reports it.
  • A timezone mismatch between producer, job and consumer, producing daily totals that are right in aggregate and wrong at every boundary.
  • The most recent, incomplete window plotted next to complete ones and read as a drop (Stale Dashboards).
  • A sink that appends, so replays and late corrections give a period several rows and additivity silently fails (Duplicate Rows).
  • The mitigation failing: emitting provisional results to improve freshness, and a consumer that reads once and caches — so it holds the provisional value permanently and never sees the final one.
Misreads
  • "Tumbling windows are the simple case, so there is nothing to get wrong." The assignment is simple. The timezone, the alignment, the boundary convention, the completeness of the last window and the additivity of the aggregate are all still there.
  • "I can average the hourly averages to get the daily average." Only if every hour has the same number of events. Averages are not decomposable; sums and counts are, which is why you should store both and divide at the end (The Metrics Layer).
  • "Distinct counts roll up like sums." They do not. Two hours each with a hundred distinct users do not make two hundred distinct users for the day, and the only ways to roll them up are a sketch or a recomputation from raw (Cardinality: The Label That Took Down Monitoring).
  • "The window emitted, so the period is closed." It emitted because the watermark passed its end. Whether it can still change depends on the lateness allowance, and whether it is *correct* depends on whether everything arrived (Late Events).

Operating it

How you see it in production
  • Rows per (window_start, key), which should be exactly one, monitored continuously rather than tested once.
  • Lag between window end and first emission, and between window end and final emission. The pair describes the consumer's actual experience of freshness (Freshness Monitoring).
  • Count of windows re-emitted after their first emission, which measures how much late correction is actually happening.
  • The roll-up difference — small windows summed versus a direct recomputation — tracked as a time series so drift is visible before it is large (The Data Quality Dashboard).
What changes at 10x and 100x
  • At 10x volume, state and output are unchanged — the aggregate absorbs more records per window without growing. This is the property that makes tumbling windows scale so comfortably.
  • At 10x keys, state and output rows scale linearly and predictably. Nothing surprising happens, which is worth saying because it is not true of the other families.
  • At 100x, skew is the only real issue: one hot key's windows sit on one instance. The remedy is a two-stage aggregation — pre-aggregate on a salted key, then merge — which works cleanly precisely because tumbling aggregates are additive (Salting a Skewed Key).
What drives cost here
  • The cheapest window family: one live state entry per key, multiplied only by the lateness allowance divided by the window size. Nothing else in the module has a smaller footprint (Streaming State).
  • Output volume is keys × windows per period, which is a rate you can compute exactly in advance. Halving the window size doubles output rows and sink writes.
  • Small windows produce many small writes, which on a lake table format means many small files and a compaction requirement. The window size is therefore also a physical layout decision (File Size and the Small-Files Problem).
What this approach costs
  • Tumbling buys additivity, minimal state and predictable cost, and costs responsiveness: a burst that starts just after a boundary is invisible until that window ends.
  • A smaller window buys freshness and finer roll-up granularity, and costs output rows, sink writes and file count in direct proportion.
  • Aligning windows to a business day buys reports that match how the business thinks and costs comparability with any other system using epoch-aligned buckets — a cost that is invisible until two teams compare numbers.

Where this applies

Almost nothing here is universal. These labels say what each claim is specific to, and where a different engine, format, warehouse or scale would differ.

  • GENERALFixed-size non-overlapping buckets and their additivity property are the same in every engine and in batch SQL; date_trunc in a warehouse produces exactly the same partition of the timeline as a streaming tumbling assigner over the same timestamp field.
  • ENGINE-SPECIFICFlink SQL expresses tumbling windows as a TUMBLE table-valued function producing window_start/window_end columns; Spark Structured Streaming uses a window() column expression that yields a struct; Kafka Streams uses a windowed key. The assignment rule is identical and the output shape and column names are not, which matters when models are ported between them.
  • SIMULATEDThe timeline is produced by src/de/sim/stream.ts with a five-minute tumbling configuration, and scripts/de-sim.test.ts asserts the partition property directly — that the total number of placements equals the number of events, with no duplicates.

Where the depth lives

This domain teaches how data moves and how you know it arrived intact. It hands the rest off by name.

Domains that do not exist yet
  • DevOps / Production Engineering owns the deployment discipline that makes a window-size change survivable: running the new definition alongside the old one, comparing, and cutting over on a recorded date rather than editing a running job.