How do you group event rows into sessions that break after 30 minutes of inactivity?
answer
- look back one row to spot the break
- turn each break into a 0/1 marker
- accumulate the markers in the same order
- the cumulative total is the run id
- LAG plus running SUM, then GROUP BY
basics
~20 sCompare each row with the previous one using LAG(); emit 1 when the elapsed time exceeds the threshold and 0 otherwise, then take a running SUM() of that flag in the same order. The cumulative sum is a session id you can GROUP BY.
solid answer
~50 sThe row-number difference trick cannot express a tolerance, so build the run id explicitly. In one pass, `LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time)` gives each row its predecessor, and a `CASE` marks the row `1` when the elapsed time exceeds 30 minutes and `0` otherwise. In a second pass, `SUM(flag) OVER (PARTITION BY user_id ORDER BY event_time ROWS UNBOUNDED PRECEDING)` accumulates that flag — it only increments at breaks, so every row of one session carries the same number. Then `GROUP BY user_id, session_id` with `MIN`, `MAX` and `COUNT` for session bounds and size. Two details worth saying: `LAG()` returns NULL on the first row of each partition, the comparison is unknown, and `CASE` falls to `ELSE 1`, correctly starting session 1; and the ids restart per partition, so the partition key must appear in the `GROUP BY`.
code
sql · 21 linesWITH flagged AS (
SELECT user_id, event_time,
CASE WHEN event_time - LAG(event_time) OVER (PARTITION BY user_id
ORDER BY event_time)
<= INTERVAL '30' MINUTE
THEN 0 ELSE 1 END AS starts_session
FROM events
),
sessioned AS (
SELECT user_id, event_time,
SUM(starts_session) OVER (PARTITION BY user_id ORDER BY event_time
ROWS UNBOUNDED PRECEDING) AS session_id
FROM flagged
)
SELECT user_id, session_id,
MIN(event_time) AS started_at,
MAX(event_time) AS ended_at,
COUNT(*) AS event_count
FROM sessioned
GROUP BY user_id, session_id
ORDER BY user_id, started_at;go deeper
Recall the two moving parts: LAG() to spot where a run breaks, and a running SUM() of that 0/1 flag to number the runs. Knowing the shape is enough here.
Explain why the cumulative sum is constant inside a run and increments only at breaks, and why the flag must be computed in a separate query block before the running SUM can read it.
Bring the production details: NULL from LAG on the first row of a partition, an explicit ROWS frame, a tiebreaker for identical timestamps, the partition key in the GROUP BY, and a row-count check that sessions reconcile with events.
Own the definition itself — a 30-minute timeout is a product decision that must match analytics and billing. Decide whether sessions are recomputed per query or materialised once, and keep a single implementation everyone reads from.
## Why the arithmetic trick does not apply `value - ROW_NUMBER()` works because "consecutive" means "exactly one step apart" — the arithmetic encodes that rule implicitly. Sessionisation has a different rule: a run continues while the elapsed time is *within* a tolerance, and the timestamps are irregular. You cannot express a tolerance by subtracting a counter, so you state the break condition in SQL and derive the run id from it. ## The two-pass construction ```sql WITH flagged AS ( SELECT user_id, event_time, CASE WHEN event_time - LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) <= INTERVAL '30' MINUTE THEN 0 ELSE 1 END AS starts_session FROM events ), sessioned AS ( SELECT user_id, event_time, SUM(starts_session) OVER (PARTITION BY user_id ORDER BY event_time ROWS UNBOUNDED PRECEDING) AS session_id FROM flagged ) SELECT user_id, session_id, MIN(event_time) AS started_at, MAX(event_time) AS ended_at, COUNT(*) AS event_count FROM sessioned GROUP BY user_id, session_id ORDER BY user_id, started_at; ``` Read it as three ideas. **Flag the breaks**: one boolean per row, computed by looking backwards one row. **Accumulate the flags**: a running total that only moves at a break, so it is constant within a session and strictly increasing across sessions. **Collapse**: ordinary grouping on that id. The two passes must be separate query blocks. A window function cannot be nested inside another window function's argument, so the flag has to be materialised in a CTE or derived table before the running `SUM()` can consume it. ## The first row of every partition `LAG()` has no previous row at the start of a partition, so it returns NULL unless you pass a default. `event_time - NULL` is NULL, `NULL <= INTERVAL '30' MINUTE` evaluates to unknown, and a `CASE` whose `WHEN` is unknown falls through to `ELSE` — yielding `1`. That is exactly right: the first event of a user opens session 1, and the running sum starts at 1 rather than 0. This is one of the few places where three-valued logic quietly does the correct thing, and saying so demonstrates you did not just copy the pattern. ## Frame and ordering details Spell the frame out as `ROWS UNBOUNDED PRECEDING` (shorthand for `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`). With an `ORDER BY` and no frame clause the default frame is range-based over peer rows, which behaves differently when several events share the exact same timestamp. Being explicit makes the running total deterministic and communicates intent. The ordering must be a total order for the result to be reproducible. If two events can carry identical timestamps, add a tiebreaker such as the event id to both `OVER` clauses; otherwise which of the tied rows is treated as the predecessor is arbitrary. ## Generalising the break condition The same skeleton handles any run definition — only the `CASE` changes. - **Value change** (collapse consecutive rows with the same status into one run): flag `1` when `status IS DISTINCT FROM LAG(status) OVER (...)`. `IS DISTINCT FROM` is the standard NULL-safe inequality, which matters because a plain `<>` returns unknown when either side is NULL and would fail to open a new run. Engines differ on spelling — MySQL offers the NULL-safe `<=>` operator — so check your engine. - **Irregular numeric step**: flag when `v - LAG(v) OVER (...) <> :step`. - **Business rule**: any predicate you can write, including one over several columns. That generality is the reason to know this construction even though the difference trick is shorter: one pattern covers sessionisation, status runs, price-change intervals and tolerance-based streaks alike. ## Cost and correctness notes Both passes need the rows ordered by the partition key and the timestamp, so the shape is one ordering reused by both window operators — provided both `OVER` clauses use the same partition and order. Keep them identical, or name the window once with a `WINDOW` clause, so nothing forces a second ordering. A useful validation: the number of sessions per user must equal the number of rows flagged `1` for that user, and the session event counts must sum to the user's event count. If they do not, the partition key is missing from the `GROUP BY` and two users' sessions have been merged — the classic bug, because session ids restart at 1 in every partition and therefore collide across users.
- What does the flag evaluate to for the very first event of a user, and is that correct?`LAG()` returns NULL there, so the elapsed-time comparison is unknown, the `WHEN` branch is not taken, and `CASE` falls to `ELSE 1`. That is correct: the first event opens a session, and the running `SUM()` starts at 1. If you had written the flag with a plain boolean expression instead of `CASE`, you would have to handle the NULL yourself.
- Why can't the running SUM and the LAG live in the same SELECT list?A window function cannot appear inside another window function's argument, so `SUM(CASE WHEN ... LAG(...) ...) OVER (...)` is invalid. The flag must be materialised as an ordinary column first, in a CTE or derived table, and the running total computed over that column in the next query block.
- When would you still prefer value minus ROW_NUMBER() over this construction?When 'consecutive' really means a fixed step of one over unique values — integer sequences, calendar days — the arithmetic form is shorter, needs one pass instead of two, and reads fine once recognised. Reach for `LAG()` plus a running `SUM()` as soon as there is a tolerance, an irregular step, or a value-change rule to express.
- Why must the partition key appear in the final GROUP BY?Session ids restart at 1 inside every partition, so user A's session 2 and user B's session 2 share a value. Grouping by `session_id` alone merges them into one bogus session spanning both users. `GROUP BY user_id, session_id` keeps the id unique within its partition.
saying these in an interview costs you the question
- Groups by session_id alone, merging different users' sessions
- Thinks LAG's NULL on the first row needs a COALESCE workaround
- Nests LAG inside the SUM OVER argument in one query block
- Leaves the running SUM frame implicit with tied timestamps
- Claims the ROW_NUMBER difference trick can express a time tolerance