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.

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.

idnamesigned_up
1Ada2026-01-05
2Ben2026-01-06
3Cy2026-01-12
4Dee2026-01-13
5Eli2026-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.

iduser_idhappened_atevent
112026-01-05 09:00:00view
212026-01-05 09:05:00run
312026-01-05 09:06:00run
412026-01-05 09:15:00enquiry
512026-01-05 09:45:00view
612026-01-05 10:20:00view
722026-01-06 10:00:00run
822026-01-06 10:05:00view

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;
Expected result · sample data6 rows
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;
Expected result · sample data19 rows
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;
Expected result · sample data2 rows
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
Your target result3 rows
session_no
2
3
4
Sample data is loaded for you.

Preparing the editor…

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.”