0% completed
Sessionizing Events
On This Page
The Table
The Gap to the Previous View
Numbering the Sessions
Sessions That Cross Midnight
Session Length and Views per Session
Choosing the Gap
Practice: Count Sessions per Visitor
Using This in an Interview
Key Takeaways
Practice Questions
A visitor to a recipe website opens a pancake recipe at 08:02, the pancake video at 08:05, and a maple syrup page at 08:19. At 12:40 the same visitor opens a tomato soup recipe, and at 13:01 a grilled cheese page. The gaps between these five page views are 3, 14, 261, and 21 minutes.
Suppose that any gap of 30 minutes or more ends a visit. Only the 261-minute gap is that long, so the five views form two visits: three views from 08:02 to 08:19, and two from 12:40 to 13:01. A run of one visitor's events, each following the previous one by less than a fixed gap, is called a session. Splitting events into sessions is called sessionizing, and the fixed gap is the inactivity timeout.
The Table
Each row is one page view on a recipe website. view_min is the clock time counted in minutes since midnight, so 08:02 is 8 * 60 + 2 = 482. Integer minutes make the gap a plain subtraction.
| view_id | visitor_id | day_no | view_time | view_min | page |
|---|---|---|---|---|---|
| 1 | V-11 | 2 | 08:02 | 482 | pancakes |
| 2 | V-11 | 2 | 08:05 | 485 | pancake-video |
| 3 | V-11 | 2 | 08:19 | 499 | maple-syrup |
| 4 | V-11 | 2 | 12:40 | 760 | tomato-soup |
| 5 | V-11 | 2 | 13:01 | 781 | grilled-cheese |
| 6 | V-22 | 2 | 23:30 | 1410 | bread-starter |
| 7 | V-22 | 2 | 23:50 | 1430 | sourdough-tips |
| 8 | V-22 | 3 | 00:10 | 10 | oven-temps |
| 9 | V-22 | 3 | 01:15 | 75 | banana-bread |
V-11 is the visitor from the opening. V-22 reads bread recipes late at night, and two of those views fall after midnight, on day 3. Every query below was run on MySQL 8.4, and the output shown is the real output.
The Gap to the Previous View
The gap of a page view is the number of minutes since the same visitor's previous view. LAG returns a value from the previous row of the partition, as Gaps and Islands explains, so the gap is the current minute minus LAG of the minute:
SELECT view_id, visitor_id, day_no, view_time, view_min - LAG(view_min) OVER ( PARTITION BY visitor_id, day_no ORDER BY view_min, view_id ) AS gap_min FROM page_views ORDER BY visitor_id, day_no, view_min, view_id;
Output:
+---------+------------+--------+-----------+---------+ | view_id | visitor_id | day_no | view_time | gap_min | +---------+------------+--------+-----------+---------+ | 1 | V-11 | 2 | 08:02 | NULL | | 2 | V-11 | 2 | 08:05 | 3 | | 3 | V-11 | 2 | 08:19 | 14 | | 4 | V-11 | 2 | 12:40 | 261 | | 5 | V-11 | 2 | 13:01 | 21 | | 6 | V-22 | 2 | 23:30 | NULL | | 7 | V-22 | 2 | 23:50 | 20 | | 8 | V-22 | 3 | 00:10 | NULL | | 9 | V-22 | 3 | 01:15 | 65 | +---------+------------+--------+-----------+---------+
V-11's gaps are the 3, 14, 261, and 21 minutes from the opening. The first view of each partition has no previous row, so its gap is NULL. Ending the ORDER BY with view_id gives two views in the same minute a fixed order. LAG ignores any frame clause, so none is written.
The partition here is visitor and day, which seems natural for daily data. But view 8 has a NULL gap, although V-22 viewed a page 20 minutes earlier. The section on midnight returns to this row.
Numbering the Sessions
A session number is a running count of the session starts in one visitor's views. It is the flag form from Gaps and Islands with a time rule. First, each view gets a flag is_new: 1 when the gap is NULL or at least 30 minutes, and 0 otherwise. Second, a running SUM of the flags, with the ROWS frame from Window Functions, numbers the sessions:
WITH gaps AS ( SELECT view_id, visitor_id, day_no, view_time, view_min, view_min - LAG(view_min) OVER ( PARTITION BY visitor_id, day_no ORDER BY view_min, view_id ) AS gap_min FROM page_views ), flagged AS ( SELECT view_id, visitor_id, day_no, view_time, view_min, gap_min, CASE WHEN gap_min IS NULL OR gap_min >= 30 THEN 1 ELSE 0 END AS is_new FROM gaps ) SELECT view_id, visitor_id, day_no, view_time, gap_min, is_new, SUM(is_new) OVER ( PARTITION BY visitor_id, day_no ORDER BY view_min, view_id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS session_no FROM flagged ORDER BY visitor_id, day_no, view_min, view_id;
Output:
+---------+------------+--------+-----------+---------+--------+------------+ | view_id | visitor_id | day_no | view_time | gap_min | is_new | session_no | +---------+------------+--------+-----------+---------+--------+------------+ | 1 | V-11 | 2 | 08:02 | NULL | 1 | 1 | | 2 | V-11 | 2 | 08:05 | 3 | 0 | 1 | | 3 | V-11 | 2 | 08:19 | 14 | 0 | 1 | | 4 | V-11 | 2 | 12:40 | 261 | 1 | 2 | | 5 | V-11 | 2 | 13:01 | 21 | 0 | 2 | | 6 | V-22 | 2 | 23:30 | NULL | 1 | 1 | | 7 | V-22 | 2 | 23:50 | 20 | 0 | 1 | | 8 | V-22 | 3 | 00:10 | NULL | 1 | 1 | | 9 | V-22 | 3 | 01:15 | 65 | 1 | 2 | +---------+------------+--------+-----------+---------+--------+------------+
V-11's flags are 1, 0, 0, 1, 0, and their running sums are 1, 1, 1, 2, 2. The number goes up by one at each view that starts a session. The NULL test matters, because without it the first view gets the flag 0.
The flag needs its own step because MySQL does not support a window function inside another one. It rejects the one-step version:
SELECT view_id, SUM(CASE WHEN view_min - LAG(view_min) OVER (ORDER BY view_id) >= 30 THEN 1 ELSE 0 END) OVER (ORDER BY view_id) AS session_no FROM page_views;
Output:
ERROR 3593 (HY000) at line 1: You cannot use the window function 'lag' in this context.'
Sessions That Cross Midnight
The previous output gives V-22 three sessions. Its view at 00:10 on day 3 came 20 minutes after the view at 23:50 on day 2. The partition starts again at each new day, though, so LAG cannot see the earlier view.
An absolute minute counts minutes from one fixed starting point, so it keeps increasing across midnight. With integer days, it is day_no * 1440 + view_min, because a day has 1,440 minutes. The view at 23:50 on day 2 becomes 2 * 1440 + 1430 = 4310, and 00:10 on day 3 becomes 3 * 1440 + 10 = 4330. The gap is 20 minutes, and the partition can now be the visitor alone. The query also names its window once in a WINDOW clause, because the flag reads LAG twice:
WITH timed AS ( SELECT view_id, visitor_id, day_no, view_time, day_no * 1440 + view_min AS abs_min FROM page_views ), flagged AS ( SELECT view_id, visitor_id, day_no, view_time, abs_min, CASE WHEN LAG(abs_min) OVER w IS NULL OR abs_min - LAG(abs_min) OVER w >= 30 THEN 1 ELSE 0 END AS is_new FROM timed WINDOW w AS (PARTITION BY visitor_id ORDER BY abs_min, view_id) ) SELECT view_id, visitor_id, day_no, view_time, abs_min, is_new, SUM(is_new) OVER ( PARTITION BY visitor_id ORDER BY abs_min, view_id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS session_no FROM flagged ORDER BY visitor_id, abs_min, view_id;
Output:
+---------+------------+--------+-----------+---------+--------+------------+ | view_id | visitor_id | day_no | view_time | abs_min | is_new | session_no | +---------+------------+--------+-----------+---------+--------+------------+ | 1 | V-11 | 2 | 08:02 | 3362 | 1 | 1 | | 2 | V-11 | 2 | 08:05 | 3365 | 0 | 1 | | 3 | V-11 | 2 | 08:19 | 3379 | 0 | 1 | | 4 | V-11 | 2 | 12:40 | 3640 | 1 | 2 | | 5 | V-11 | 2 | 13:01 | 3661 | 0 | 2 | | 6 | V-22 | 2 | 23:30 | 4290 | 1 | 1 | | 7 | V-22 | 2 | 23:50 | 4310 | 0 | 1 | | 8 | V-22 | 3 | 00:10 | 4330 | 0 | 1 | | 9 | V-22 | 3 | 01:15 | 4395 | 1 | 2 | +---------+------------+--------+-----------+---------+--------+------------+
V-22 now has two sessions: views 6, 7, and 8 form session 1, and view 9 starts session 2 after a 65-minute gap. V-11 is unchanged, because all its views fall on one day.
The two partitions compare as follows.
| Partition by visitor and day | Absolute minute, partition by visitor | |
|---|---|---|
| V-22 sessions | 3 | 2 |
| A session across midnight | Split in two | Kept whole |
| Input for one day's sessions | That day only | That day and the end of the previous day |
| A finished day's sessions | Never change | Can change when the next day arrives |
- Cost of the absolute minute: a daily job must read across the boundary. A job that sessionizes day 3 alone cannot see the 23:50 view. It must also read at least the last 30 minutes of day 2. A session that started on day 2 may then gain views, so day 2's figures change after they were written.
- When splitting at midnight is better: a report that defines sessions per calendar day, and whose published days must never change. The split is then part of the definition, and the report should state it.
Session Length and Views per Session
Session length is the time from the first view of a session to the last, and views per session is the number of page views in it. Both come from a GROUP BY on the visitor and session number, applied after the windows:
WITH timed AS ( SELECT view_id, visitor_id, day_no, day_no * 1440 + view_min AS abs_min FROM page_views ), flagged AS ( SELECT view_id, visitor_id, day_no, abs_min, CASE WHEN LAG(abs_min) OVER w IS NULL OR abs_min - LAG(abs_min) OVER w >= 30 THEN 1 ELSE 0 END AS is_new FROM timed WINDOW w AS (PARTITION BY visitor_id ORDER BY abs_min, view_id) ), numbered AS ( SELECT visitor_id, day_no, abs_min, SUM(is_new) OVER ( PARTITION BY visitor_id ORDER BY abs_min, view_id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS session_no FROM flagged ) SELECT visitor_id, session_no, MIN(day_no) AS start_day, COUNT(*) AS views, MAX(abs_min) - MIN(abs_min) AS length_min FROM numbered GROUP BY visitor_id, session_no ORDER BY visitor_id, session_no;
Output:
+------------+------------+-----------+-------+------------+ | visitor_id | session_no | start_day | views | length_min | +------------+------------+-----------+-------+------------+ | V-11 | 1 | 2 | 3 | 17 | | V-11 | 2 | 2 | 2 | 21 | | V-22 | 1 | 2 | 3 | 40 | | V-22 | 2 | 3 | 1 | 0 | +------------+------------+-----------+-------+------------+
V-22's first session runs from minute 4290 to minute 4330, which is 40 minutes across midnight. The length uses abs_min for the same reason as the gap: MAX(view_min) - MIN(view_min) would give 1430 - 10 = 1420 minutes for that session.
The length stops at the last view, so the time spent on the last page is not counted, and a one-view session has length 0. The average over all four sessions is (17 + 21 + 40 + 0) / 4 = 19.5 minutes, and without the one-view session it is 78 / 3 = 26. Neither is wrong, but the metric's definition must say which one it reports.
Choosing the Gap
The timeout decides every number above. One query can count sessions for several timeouts at once, by joining the gaps to a list of candidate values:
WITH timed AS ( SELECT view_id, visitor_id, day_no * 1440 + view_min AS abs_min FROM page_views ), gaps AS ( SELECT abs_min - LAG(abs_min) OVER ( PARTITION BY visitor_id ORDER BY abs_min, view_id ) AS gap_min FROM timed ), limits AS ( SELECT 10 AS gap_limit UNION ALL SELECT 20 UNION ALL SELECT 30 UNION ALL SELECT 60 UNION ALL SELECT 300 ) SELECT l.gap_limit, SUM(CASE WHEN g.gap_min IS NULL OR g.gap_min >= l.gap_limit THEN 1 ELSE 0 END) AS sessions FROM limits l CROSS JOIN gaps g GROUP BY l.gap_limit ORDER BY l.gap_limit;
Output:
+-----------+----------+ | gap_limit | sessions | +-----------+----------+ | 10 | 8 | | 20 | 7 | | 30 | 4 | | 60 | 4 | | 300 | 2 | +-----------+----------+
The count falls from 8 to 4 between 10 and 30 minutes, and then stays at 4 up to 60. No gap in the table lies strictly between 21 and 65 minutes, so every timeout from 22 to 65 splits the views at the same two gaps, 261 and 65. On a large table, the same query over more values shows the range where the count changes least, and a timeout inside that range is the least sensitive choice.
- A short timeout gives more, shorter sessions. At 10 minutes, the 14-minute pause on the pancake video ends V-11's first session, although all three views are about one breakfast.
- A long timeout gives fewer, longer sessions. At 300 minutes, V-11's breakfast and lunch visits become one session of 3661 - 3362 = 299 minutes.
Google Analytics ends a session after 30 minutes of inactivity by default, so a 30-minute timeout makes a warehouse count easier to compare with its reports. An administrator can change that timeout there, and a session has no maximum length.
Practice: Count Sessions per Visitor
The site's analytics team uses a 20-minute rule: a view that comes 20 minutes or more after the same visitor's previous view starts a new session. A session may cross midnight. The page_views table has the columns shown above. Order each visitor's views by day_no, then view_min, then view_id, and not by view_id alone, because some views were recorded late.
For each visitor, return these columns in this order:
visitor_id.sessions: the number of sessions.views: the number of page views, counting a repeated page each time.longest_min: the length in minutes of the visitor's longest session, from its first view to its last, which is 0 for a one-view session.
Sort the result by visitor_id.
Using This in an Interview
Before writing anything, ask what the timeout is and whether a gap equal to it starts a new session. Then describe the plan while writing: "I compute each event's gap to the user's previous event with LAG and flag gaps at or above the timeout. A running sum of the flags is the session number. I build the time from the day and the minute, so a session can cross midnight." Two follow-up questions, with answers:
- "How would you do this on a stream of events?" Spark Structured Streaming has session windows, computed with its
session_windowfunction. A session window starts with an event and grows while the next event arrives within the gap duration, which is the rule the SQL above applies. - "Your job runs once a day. What happens to a session that crosses midnight?" The job must also read the end of the previous day, at least one timeout's worth, and then either update the session that started there or state that sessions are split at midnight.
Key Takeaways
- A session is a run of one user's events in which each event follows the previous one by less than a fixed timeout.
- The gap is the current time minus LAG of the time, and the first event of each user has a NULL gap that must also start a session.
- A running SUM of the "new session" flags numbers the sessions; the flag needs its own step, because MySQL does not allow a window inside another window.
- Use an absolute time such as
day_no * 1440 + view_minfor the gap, the order, and the length, or sessions split at midnight and lengths come out wrong. - Choose the timeout inside a range where the session count changes least, and state how one-event sessions are counted in length metrics.
Every count in this lesson assumes that each page view appears once in the table. A tracking call that is retried can record the same view twice, which adds a view to a session and a gap of 0 minutes. The next lesson, Removing Duplicates, shows how to find such rows and keep exactly one of each.
Practice Questions
1. A visitor views pages at 10:00, 10:25, 10:58, and 11:20 on one day. With a 30-minute timeout, how many sessions are there, and how long is each?
<details> <summary>Show answer</summary>There are two sessions, of 25 and 22 minutes. The gaps are 25, 33, and 22 minutes. Only the 33-minute gap reaches 30, so the view at 10:58 starts the second session. The first session runs from 10:00 to 10:25, and the second from 10:58 to 11:20.
</details>2. A query flags new sessions with CASE WHEN gap_min >= 30 THEN 1 ELSE 0 END, without testing for NULL. What goes wrong?
The first view of each visitor gets the flag 0, so the running sum starts at 0. NULL compared with 30 is not true, so the CASE returns 0. The sessions are still separated, but they are numbered from 0, and a session count taken as the sum of the flags is one short in every partition.
</details>3. A visitor views a page at 23:40 on day 4 and another at 00:05 on day 5. What are the absolute minutes, and is this one session with a 30-minute timeout?
<details> <summary>Show answer</summary>The absolute minutes are 7180 and 7205, so the gap is 25 minutes and it is one session. Day 4 starts at 4 * 1440 = 5760, and 23:40 is minute 1420, giving 7180. Day 5 starts at 7200, so 00:05 is 7205. Subtracting minutes of day alone would give 5 - 1420 = -1415.
</details>4. Why can the session number not be written as one expression, SUM(CASE WHEN ... LAG(...) OVER (...) ... END) OVER (...)?
MySQL does not support a window function inside another window function. It rejects the expression with error 3593. The fix is to compute the flag in one CTE and the running sum in the next query, so each window works on a column that already exists.
</details>5. On the recipe table, a timeout of 30 minutes and a timeout of 60 minutes both give 4 sessions. What does that tell you about the data?
<details> <summary>Show answer</summary>No gap in the table falls between 30 and 59 minutes. The gaps are 3, 14, 261, 21, 20, 20, and 65 minutes, so both timeouts split the views at the same two gaps, 261 and 65. A timeout anywhere in that range gives the same sessions, which makes the result insensitive to the exact choice.
</details>Reading Progress
0%
On This Page
The Table
The Gap to the Previous View
Numbering the Sessions
Sessions That Cross Midnight
Session Length and Views per Session
Choosing the Gap
Practice: Count Sessions per Visitor
Using This in an Interview
Key Takeaways
Practice Questions