datarekha

Sessionization

Turn an event stream into sessions — the gap-and-islands pattern every analyst eventually writes, built step by step with window functions.

8 min read Advanced SQL Lesson 18 of 27

What you'll learn

  • Why sessionization is the gap-and-islands problem
  • Using LAG() to find the time since the previous event
  • Marking session boundaries, then numbering sessions with a running sum
  • Why the gap threshold is a product decision, not a SQL one

Before you start

A stream of events — clicks, orders, log lines — says little on its own. You want to group it: which events belong to the same visit? That is sessionization, and the SQL pattern behind it is gaps and islands — find the gaps (pauses longer than a threshold), and the runs of events between them are the islands, each one a session.

What counts as a gap is yours to define: a website usually calls 30 minutes of inactivity a new session; a B2B tool might use 24 hours. We will use a 7-day threshold for a user’s orders. Watch one user whose four orders fall into two shopping runs:

16-day gap > 7 → new sessionJan 1Jan 4Jan 20Jan 22session 1session 2
Two orders close together, a long gap, then two more: two islands separated by one gap.

The build, in one query

Four moves do it. LAG gets the previous order’s date, a subtraction gives the gap in days, a CASE flags each row that starts a new session (a first order, or a gap over 7 days), and a running sum of that flag becomes the session number — it ticks up only at boundaries and stays flat within a session:

WITH gaps AS (
  SELECT id, order_date,
         CAST(julianday(order_date)
            - julianday(LAG(order_date) OVER (ORDER BY order_date)) AS INT) AS days_since_prev
  FROM   orders
),
flagged AS (
  SELECT id, order_date,
         CASE WHEN days_since_prev IS NULL OR days_since_prev > 7 THEN 1 ELSE 0 END AS is_new_session,
         days_since_prev
  FROM   gaps
)
SELECT id, order_date, days_since_prev, is_new_session,
       SUM(is_new_session) OVER (ORDER BY order_date ROWS UNBOUNDED PRECEDING) AS session_id
FROM   flagged;
idorder_datedays_since_previs_new_sessionsession_id
12024-01-01NULL11
22024-01-04301
32024-01-201612
42024-01-22202

Read the running sum: it is 1 for the first two orders (one boundary so far), then jumps to 2 at order 3 where the 16-day gap tripped the flag. Every order now carries a session_id, so a final GROUP BY session_id rolls each island up to one row:

session_idstartendordersspan_days
12024-01-012024-01-0423
22024-01-202024-01-2222

That is the artifact a stakeholder wants: when each session started and ended, how many orders, how long it spanned. (Per user, you simply add PARTITION BY user_id to every window.)

Practice

Quick check

0/3
Q1What problem does sessionization solve?
Q2Why use a running sum of is_new_session as the session_id?
Q3Why is the 7-day threshold a domain decision, not a SQL one?

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
What is the gaps-and-islands problem, and how do you solve it with window functions?

Gaps-and-islands is the problem of identifying contiguous ranges (islands) within ordered sequential data and the breaks (gaps) between them. The classic solution subtracts a dense sequential integer from the ordering column — equal differences belong to the same island.

What is the difference between batch and streaming data pipelines, and how do you choose between them?

Batch pipelines process data in bounded chunks on a schedule — simple to build and test, but latency is measured in hours or days. Streaming pipelines process records continuously as they arrive — latency drops to seconds or milliseconds, but correctness requires handling late arrivals, watermarks, and stateful aggregations. Choose streaming when business decisions need fresh data; choose batch when daily freshness is acceptable and operational simplicity matters.

How would you calculate a 7-day moving average of daily sales, and what frame clause is needed?

Use AVG() with a ROWS frame specifying 6 PRECEDING to current row — this captures exactly 7 physical rows regardless of date gaps. RANGE with INTERVAL '6 days' PRECEDING is the alternative when you need a true calendar window, but it requires at most one row per date and may include fewer than 7 rows if days are missing.

How do you compute a running total (cumulative sum) using a window function, and what frame clause does it use by default?

Use SUM() OVER (ORDER BY ...) — the default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. This works correctly for strictly increasing order columns, but silently over-counts when multiple rows share the same ORDER BY value because the RANGE default includes all peers.

Related lessons

Explore further

Skip to content