Skip to main content

Tumbling and Hopping Windows

Level 9: Streaming & Change Data Capturemedium26 mintumbling windowshopping windowsevent-time bucketingepoch arithmeticGROUP BYrange joins

Bucket an event stream into tumbling windows with epoch arithmetic, then into overlapping hopping windows with a join against a window table.

Why a stream needs windows at all

A stream has no end, so no aggregate over it can ever finish. "How many checkouts happened" has no answer against an infinite log; "how many checkouts happened between 17:05 and 17:10" has exactly one. A window is the device that turns an unbounded stream into a sequence of finite groups you can actually aggregate, and every stream engine ships the same three kinds: tumbling, hopping, and session.

Deepen-not-repeat note: sql-l5-system-design-round-reasoning introduced the word "tumbling window" as vocabulary. What is new here is implementing one, the half-open bound that keeps events from being double counted, and hopping windows, which that lesson never touched.

Tumbling: fixed size, no overlap

A tumbling window has a size and nothing else. Windows sit end to end, they never overlap, and every event belongs to exactly one of them. That property is what makes tumbling windows safe for anything you sum, because no event is counted twice.

You do not need a window table to build them. Integer division floors an event time down onto the window it belongs to:

(event_time_ms / 300000) * 300000

Read it left to right. event_time_ms / 300000 in SQLite is integer division when both operands are integers, so it throws away the remainder and tells you how many whole 5-minute windows have elapsed since the epoch. Multiplying back by 300000 turns that window index into the window's start time. Change 300000 to 60000 and you have 1-minute windows; change it to 3600000 and you have hourly ones. This one expression is the whole mechanism.

The bounds are half-open: a window covers [start, start + size), start inclusive and end exclusive. An event landing exactly on a boundary belongs to the window it opens, never to the one that just closed.

Hopping: fixed size, fixed hop, deliberate overlap

A hopping window (Flink and Spark call this a sliding window; be careful, because in Azure Stream Analytics and the Fabric Eventstreams built on it, Sliding is a distinct window type that emits only when an event enters or leaves it, and Hopping is the one with the fixed hop) has two numbers: a size and a hop. Size 10 minutes with a hop of 5 minutes means a new window opens every 5 minutes and each one covers 10 minutes, so consecutive windows overlap by half and every event falls into two of them. That is the point: hopping windows smooth a metric, which is why "10-minute rolling error rate, refreshed every minute" is a hopping window and not a tumbling one.

Table
One timeline sliced twice. Tumbling puts each event in exactly one bucket; hopping (size 10, hop 5) puts each event in two, so the hopping counts sum to twice the event total.
event (min:sec into the slice)tumbling 5 minhopping window Ahopping window B
00:12[00, 05)[-05, 05)[00, 10)
05:05[05, 10)[00, 10)[05, 15)
10:40[10, 15)[05, 15)[10, 20)
15:00[15, 20)[10, 20)[15, 25)
29:55[25, 30)[20, 30)[25, 35)
One timeline sliced twice. Tumbling puts each event in exactly one bucket; hopping (size 10, hop 5) puts each event in two, so the hopping counts sum to twice the event total.

No single GROUP BY expression can put one row into two groups, so hopping windows are built differently: you enumerate the windows in a table and join the events against it on a range.

FROM hop_windows w
JOIN stream_events e
  ON e.event_time_ms >= w.window_start_ms
 AND e.event_time_ms <  w.window_end_ms

That join is not a workaround. It is the same shape the engine's operator keeps in memory: a set of open windows, each accumulating the events that fall inside it, each closing on its own schedule.

Look again at the 15:00 row of the diagram. That event lands exactly on a boundary, and the seed you are about to query contains one just like it.

Common mistake: writing the range join with BETWEEN. BETWEEN is inclusive on both ends, so an event landing exactly on window_end_ms gets counted in the window that just closed AND in the two it genuinely belongs to, and your hopping totals quietly exceed twice the event count. Half-open bounds (>= start AND < end) are the convention every engine uses, and matching them is the difference between a correct answer and one that is off by a rounding error nobody notices for a month.

Interview nuance: "tumbling versus hopping versus session" is a standard vocabulary check, and the answer that lands is the one that names the invariant rather than the shape. Tumbling: every event in exactly one window, safe to sum. Hopping: overlapping, so an event contributes to several windows and totals across windows are intentionally larger than the event count. Session: data-driven boundaries, covered in the closing lesson of this module. If the loop is a cloud-specific one, check whose vocabulary you are speaking: "sliding" is the overlapping fixed-hop window in Flink and Spark, but a separate event-triggered window type in Azure Stream Analytics and Fabric.

On a real platform this differs. Older Flink SQL writes these as group windows, TUMBLE(event_time, INTERVAL '5' MINUTE) and HOP(event_time, INTERVAL '5' MINUTE, INTERVAL '10' MINUTE); current Flink uses the windowing table-valued functions instead, TUMBLE(TABLE t, DESCRIPTOR(event_time), INTERVAL '5' MINUTE) and HOP(TABLE t, DESCRIPTOR(event_time), INTERVAL '5' MINUTE, INTERVAL '10' MINUTE), with the group-window form deprecated from 1.20 onward. Spark Structured Streaming writes window(event_time, "10 minutes", "5 minutes"), and Microsoft Fabric Eventstreams offers tumbling, hopping, and session windows in its query editor alongside two of its own. All of them are doing the bucketing you are about to write by hand, holding the open windows as operator state and emitting each one when a watermark says it is safe. The arithmetic is identical; the engine just owns the state.

Sample data for this example
CREATE TABLE stream_events (
  event_id           INTEGER,
  user_id            INTEGER,
  event_type         TEXT,
  event_time_ms      INTEGER,  -- when the event HAPPENED, stamped by the client
  processing_time_ms INTEGER   -- when the pipeline SAW it; ignored until the next lesson
);
INSERT INTO stream_events (event_id, user_id, event_type, event_time_ms, processing_time_ms) VALUES
  (7001, 101,     'page_view', 1767286812000, 1767286816000),
  (7002, 102,        'search', 1767286847000, 1767286853000),
  (7003, 101,  'product_view', 1767286930000, 1767286933000),
  (7004, 103,     'page_view', 1767287001000, 1767287010000),
  (7005, 102,   'add_to_cart', 1767287088000, 1767287093000),
  (7006, 104,     'page_view', 1767287105000, 1767287109000),
  (7007, 104,        'search', 1767287122000, 1767287129000),
  (7008, 105,     'page_view', 1767287160000, 1767287165000),
  (7009, 104,  'product_view', 1767287201000, 1767287207000),
  (7010, 104,   'add_to_cart', 1767287215000, 1767287223000),
  (7011, 106,     'page_view', 1767287270000, 1767287273000),
  (7012, 101,      'checkout', 1767287301000, 1767287312000),
  (7013, 103,        'search', 1767287340000, 1767287344000),
  (7014, 105,  'product_view', 1767287388000, 1767287394000),
  (7015, 101,     'page_view', 1767287440000, 1767287445000),
  (7016, 106,        'search', 1767287515000, 1767287523000),
  (7017, 104,      'checkout', 1767287660000, 1767287664000),
  (7018, 102,     'page_view', 1767287700000, 1767287706000),
  (7019, 103,  'product_view', 1767287718000, 1767287721000),
  (7020, 104,     'page_view', 1767287735000, 1767287742000),
  (7021, 105,        'search', 1767287760000, 1767287765000),
  (7022, 101,   'add_to_cart', 1767287788000, 1767287797000),
  (7023, 106,  'product_view', 1767287810000, 1767287814000),
  (7024, 102,        'search', 1767287844000, 1767287850000),
  (7025, 104,     'page_view', 1767287871000, 1767287874000),
  (7026, 103,      'checkout', 1767287899000, 1767287911000),
  (7027, 105,   'add_to_cart', 1767287920000, 1767287925000),
  (7028, 101,     'page_view', 1767287955000, 1767287959000),
  (7029, 106,      'checkout', 1767287990000, 1767287998000),
  (7030, 104,        'search', 1767288003000, 1767288008000),
  (7031, 102,  'product_view', 1767288050000, 1767288057000),
  (7032, 101,      'checkout', 1767288101000, 1767288111000),
  (7033, 105,     'page_view', 1767288155000, 1767288159000),
  (7034, 104,   'add_to_cart', 1767288200000, 1767288206000),
  (7035, 103,     'page_view', 1767288242000, 1767288245000),
  (7036, 106,        'search', 1767288290000, 1767288299000),
  (7037, 105,      'checkout', 1767288310000, 1767288321000),
  (7038, 104,     'page_view', 1767288402000, 1767288407000),
  (7039, 102,     'page_view', 1767288500000, 1767288504000),
  (7040, 101,        'search', 1767288595000, 1767288602000);
Worked example (SQL)
-- The tumbling bucket expression next to the raw event time. Watch the first five events
-- collapse onto one window start, and the sixth open the next window.
SELECT event_id,
       event_time_ms,
       (event_time_ms / 300000) * 300000 AS window_start_ms,
       event_time_ms - (event_time_ms / 300000) * 300000 AS ms_into_window
FROM stream_events
ORDER BY event_time_ms
LIMIT 8;

Apply

Your turn

The task this lesson builds to.

Write a query that returns the 5-minute tumbling window counts of the stream, as (window_start_ms, event_count), oldest window first, over stream_events(event_id, user_id, event_type, event_time_ms, processing_time_ms).

Bucket each event by its event_time_ms into a 5-minute window (300000 ms) and count the events per window. Alias the columns exactly window_start_ms and event_count.

3 hints and 1 automated check are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, plus 3 bonus drills.

Write a query that returns the event count in every hopping window, as (window_start_ms, window_end_ms, event_count), oldest window first, over stream_events and hop_windows(window_start_ms, window_end_ms).

hop_windows already enumerates the 10-minute windows that open every 5 minutes, so an event belongs to two of them and the counts across windows add up to more than the number of events. Treat each window as half-open: an event counts when its event_time_ms is at or after window_start_ms and strictly before window_end_ms. Alias the count exactly event_count.

2 hints and 1 automated check are waiting in the workspace.