Ada reads a tutorial, runs the example twice, and sends an enquiry. Forty-five minutes after her first click, she opens another page. Is that still one visit? The answer depends on the gaps between events, not simply the time since arrival.
We will turn a tiny event log into sessions, then calculate something useful about each one. Change the threshold in the illustration before running the queries.
Explore the idea
When does Ada’s next visit begin?
Change the inactivity rule. Exactly the threshold still belongs to the same session.
2
sessions on January 5
A 30-minute gap stays; 35 minutes starts again
The first five events belong together. Only the 10:20 event starts another session. The elapsed time since arrival is not the inactivity gap.
3
sessions on January 5
A tighter threshold splits two gaps
Both the 30-minute and 35-minute gaps exceed 20 minutes. The same six clicks now describe three sessions.
1
session on January 5
All adjacent gaps fit inside the rule
The whole 80-minute span becomes one session because no gap between neighboring events exceeds 45 minutes.
Start with events you can inspect
Our fictional website records view, run, and enquiry events. user_id is a stable identifier; id uniquely identifies an event. Timestamps share one UTC clock, stored here as timezone-free TIMESTAMP values for a self-contained exercise.
visitors5 rows
Meet the fictional visitors
Five stable user IDs; signup dates also anchor the retention lesson.
| id | name | signed_up |
|---|---|---|
| 1 | Ada | 2026-01-05 |
| 2 | Ben | 2026-01-06 |
| 3 | Cy | 2026-01-12 |
| 4 | Dee | 2026-01-13 |
| 5 | Eli | 2026-01-19 |
events19 rows
A tiny website event log
All 19 synthetic events are included in the CSV and setup. Times use one shared UTC clock.
| id | user_id | happened_at | event |
|---|---|---|---|
| 1 | 1 | 2026-01-05 09:00:00 | view |
| 2 | 1 | 2026-01-05 09:05:00 | run |
| 3 | 1 | 2026-01-05 09:06:00 | run |
| 4 | 1 | 2026-01-05 09:15:00 | enquiry |
| 5 | 1 | 2026-01-05 09:45:00 | view |
| 6 | 1 | 2026-01-05 10:20:00 | view |
| 7 | 2 | 2026-01-06 10:00:00 | run |
| 8 | 2 | 2026-01-06 10:05:00 | view |
The rules are deliberate: the first event starts a session; a gap greater than 30 minutes starts another; a gap of exactly 30 minutes stays in the current session. Sessions may cross midnight. This is our analytical definition, not a reproduction of Google Analytics session rules.
Look backward one event
lag reads the preceding row in a window. First, examine Ada’s January 5 events:
Inspect Ada’s gaps before assigning sessions
SELECT id, event, happened_at::TIME::VARCHAR AS time,
date_diff('minute', lag(happened_at) OVER (
ORDER BY happened_at, id), happened_at) AS gap_minutes
FROM events WHERE user_id = 1 AND happened_at::DATE = DATE '2026-01-05'
ORDER BY happened_at, id;
| id | event | time | gap_minutes |
|---|---|---|---|
| 1 | view | 09:00:00 | NULL |
| 2 | run | 09:05:00 | 5 |
| 3 | run | 09:06:00 | 1 |
| 4 | enquiry | 09:15:00 | 9 |
| 5 | view | 09:45:00 | 30 |
| 6 | view | 10:20:00 | 35 |
Scroll for more columns →
The first gap is NULL because there is no earlier event. The 09:45 click is 30 minutes after the enquiry, so it stays. The 10:20 click is 35 minutes after that, so it starts a new session. Comparing every event to 09:00 would answer a different question.
Mark starts, then count them
We calculate the previous timestamp separately for each user. Each session start contributes a one; all other events contribute a zero. A running sum turns those markers into session numbers.
Start a session only when the gap exceeds 30 minutes
WITH gaps AS (
SELECT *, lag(happened_at) OVER (
PARTITION BY user_id ORDER BY happened_at, id
) AS previous_at FROM events
), starts AS (
SELECT *, CASE WHEN previous_at IS NULL
OR happened_at - previous_at > INTERVAL 30 MINUTE
THEN 1 ELSE 0 END AS new_session
FROM gaps
)
SELECT id, user_id, happened_at, event,
sum(new_session) OVER (
PARTITION BY user_id ORDER BY happened_at, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)::INTEGER AS session_no
FROM starts
ORDER BY user_id, happened_at, id;
| id | user_id | happened_at | event | session_no |
|---|---|---|---|---|
| 1 | 1 | 2026-01-05 09:00:00 | view | 1 |
| 2 | 1 | 2026-01-05 09:05:00 | run | 1 |
| 3 | 1 | 2026-01-05 09:06:00 | run | 1 |
| 4 | 1 | 2026-01-05 09:15:00 | enquiry | 1 |
| 5 | 1 | 2026-01-05 09:45:00 | view | 1 |
| 6 | 1 | 2026-01-05 10:20:00 | view | 2 |
| 16 | 1 | 2026-01-14 12:00:00 | view | 3 |
| 19 | 1 | 2026-01-20 14:00:00 | view | 4 |
| 7 | 2 | 2026-01-06 10:00:00 | run | 1 |
| 8 | 2 | 2026-01-06 10:05:00 | view | 1 |
| 9 | 2 | 2026-01-06 10:08:00 | enquiry | 1 |
| 17 | 2 | 2026-01-20 12:00:00 | view | 2 |
| 10 | 3 | 2026-01-12 09:00:00 | view | 1 |
| 11 | 3 | 2026-01-12 09:02:00 | run | 1 |
| 12 | 3 | 2026-01-12 10:00:00 | enquiry | 2 |
| 13 | 4 | 2026-01-13 11:00:00 | view | 1 |
| 14 | 4 | 2026-01-13 11:04:00 | run | 1 |
| 15 | 4 | 2026-01-13 11:12:00 | enquiry | 1 |
| 18 | 5 | 2026-01-19 09:00:00 | view | 1 |
Scroll for more columns →
The two CTEs keep each step inspectable. PARTITION BY user_id prevents one person’s events from affecting another’s. Ordering by timestamp and unique event ID makes ties deterministic. Equal timestamps have a zero gap under this policy.
The explicit ROWS frame accumulates through the current event. The identifier is the pair (user_id, session_no): session 1 for Ada is different from session 1 for Ben. The setup also saves this query as the session_events view so later examples can use it independently.
Give the sessions meaning
Now summarize Ada’s first day:
Inspect Ada’s first two sessions
SELECT session_no, count(*) AS events,
min(happened_at)::TIME::VARCHAR AS start_time,
date_diff('minute', min(happened_at), max(happened_at)) AS observed_minutes,
bool_or(event IN ('run','enquiry')) AS engaged
FROM session_events
WHERE user_id = 1 AND happened_at::DATE = DATE '2026-01-05'
GROUP BY session_no ORDER BY session_no;
| session_no | events | start_time | observed_minutes | engaged |
|---|---|---|---|---|
| 1 | 5 | 09:00:00 | 45 | true |
| 2 | 1 | 10:20:00 | 0 | false |
Scroll for more columns →
Her first session has five events and 45 minutes between its first and last recorded events. The second has one event and zero observed minutes. Zero means we have no later timestamp to measure against; it does not mean she left instantly.
Our engagement flag means “at least one run or enquiry.” Repeated runs remain events but do not multiply engaged sessions. Different products may need a different engagement definition.
Your turn · check your answer
Find Ada’s single-event sessions
Return session_no for Ada (user_id = 1) where the session contains exactly one event, across the whole dataset.
See the expected result
| session_no |
|---|
| 2 |
| 3 |
| 4 |
Preparing the editor…
Your result
Need a hint?
Group the already assigned events by session, then filter groups with HAVING.
Show a solution
SELECT session_no FROM session_events WHERE user_id = 1 GROUP BY session_no HAVING count(*) = 1 ORDER BY session_no;
Keep the definition reproducible
Late-arriving events can bridge an apparent gap and change historical session numbers. If these identifiers leave your analysis, choose a stable assignment and backfill policy instead of treating a recalculated row number as a permanent business key. Deduplicate retransmitted event IDs before sessionizing; they otherwise inflate event counts.
A useful next step is building an ordered conversion funnel from these same sessions. That lesson distinguishes “all three events happened” from “the visitor followed the intended sequence.”