Readineer
PATTERN 24SQL Patterns / Advanced SQL

SQL sessionization and event-sequence analysis: turning events into user journeys

Learn how to group timestamped events into sessions, assign stable session IDs, analyze the order of actions within each session, calculate session duration and entry or exit events, and avoid silently connecting events that belong to different user journeys.

8 min read11 sections2 predictions
01Foundation

Event rows are not yet user journeys

Many modern systems record activity as a stream of events. The event table tells us what happened, but it does not automatically tell us which events belong to the same visit or journey. Consider the following events:

User Events
event_iduser_idevent_atevent_name
10112026-07-01 09:00:00app_open
10212026-07-01 09:05:00view_product
10312026-07-01 09:12:00add_to_cart
10412026-07-01 10:05:00app_open
10512026-07-01 10:10:00search
10622026-07-01 11:00:00app_open
10722026-07-01 11:08:00view_product
10822026-07-01 11:20:00checkout_started
10922026-07-01 11:25:00purchase
11022026-07-01 14:00:00app_open

For user 1, the first three events occur fairly close together (09:00, 09:05, 09:12), then there is a much longer gap from 09:12 to 10:05. Should 10:05 belong to the same visit? That depends on the session definition. For this article, a new session begins when more than 30 minutes have passed since the user’s previous event. With that rule, user 1 has one session for 09:00 to 09:12 and a second for 10:05 to 10:10.

Key idea
A session is not a stored fact unless the source system explicitly provides one. It is a grouping created from user + event order + inactivity rule.
02Order and gap

Define the event sequence, then the gap

Before SQL can decide whether two events belong together, it must know which came first. For each user, order by event_at, event_id (event_id breaks ties, because two events can share a timestamp). Then find the previous event time with LAG:

Query
SELECT
    event_id,
    user_id,
    event_at,
    event_name,
    LAG(event_at) OVER (
        PARTITION BY user_id
        ORDER BY event_at, event_id
    ) AS previous_event_at
FROM user_events
ORDER BY user_id, event_at, event_id;
User 1
event_atevent_nameprevious_event_at
09:00app_openNULL
09:05view_product09:00
09:12add_to_cart09:05
10:05app_open09:12
10:10search10:05

For each row, the inactivity gap is event_at - previous_event_at. For user 1: no previous at 09:00, then 5, 7, 53, and 5 minutes. The rule “more than 30 minutes of inactivity starts a new session” means the 10:05 event (a 53-minute gap) begins a new session.

Mark session starts

Convert the rule into a flag, where the first event for every user must start a session because there is no previous event:

Query
WITH ordered_events AS (
    SELECT
        event_id, user_id, event_at, event_name,
        LAG(event_at) OVER (
            PARTITION BY user_id
            ORDER BY event_at, event_id
        ) AS previous_event_at
    FROM user_events
)
SELECT
    event_id, user_id, event_at, event_name,
    CASE
        WHEN previous_event_at IS NULL THEN 1
        WHEN event_at - previous_event_at > INTERVAL '30 minutes' THEN 1
        ELSE 0
    END AS new_session
FROM ordered_events
ORDER BY user_id, event_at, event_id;
User 1
event_atevent_namenew_session
09:00app_open1
09:05view_product0
09:12add_to_cart0
10:05app_open1
10:10search0

We have identified where sessions begin. Now we need to assign a session number.

03Session IDs

Turn session starts into session IDs

The pattern is the same one used for gaps and islands: cumulatively sum the session-start flag:

Query
WITH ordered_events AS (
    SELECT
        event_id, user_id, event_at, event_name,
        LAG(event_at) OVER (
            PARTITION BY user_id
            ORDER BY event_at, event_id
        ) AS previous_event_at
    FROM user_events
),
marked_events AS (
    SELECT
        *,
        CASE
            WHEN previous_event_at IS NULL THEN 1
            WHEN event_at - previous_event_at > INTERVAL '30 minutes' THEN 1
            ELSE 0
        END AS new_session
    FROM ordered_events
)
SELECT
    event_id, user_id, event_at, event_name,
    SUM(new_session) OVER (
        PARTITION BY user_id
        ORDER BY event_at, event_id
        ROWS BETWEEN UNBOUNDED PRECEDING
                 AND CURRENT ROW
    ) AS session_number
FROM marked_events
ORDER BY user_id, event_at, event_id;
Result
user_idevent_atevent_namesession_number
109:00app_open1
109:05view_product1
109:12add_to_cart1
110:05app_open2
110:10search2
211:00app_open1
211:08view_product1
211:20checkout_started1
211:25purchase1
214:00app_open2

Each row now belongs to a session. The number session 1 is unique only within one user: user 1 has a session 1 and user 2 also has a session 1. So the real session identity is user_id + session_number. For analytical SQL, keeping that pair is usually enough.

04The featured failure

Sessionizing events without partitioning by user

Suppose someone writes LAG(event_at) OVER (ORDER BY event_at, event_id) instead of partitioning by user_id. Now the previous event may belong to another user. Between user 1’s 10:10 search and user 2’s 11:00 app_open, SQL may compute 11:00 - 10:10 = 50 minutes and use that to decide whether user 2 begins a session.

PAUSE & PREDICTWhy is that wrong?
Query
LAG(event_at) OVER (
    ORDER BY event_at, event_id   -- no PARTITION BY user_id
)

-- User 1: 10:10 search
-- User 2: 11:00 app_open   -> gap computed as 50 min across users

-- A: sessions cannot use timestamps
-- B: the 10:10 event belongs to another user
-- C: 50 minutes is not longer than 30 minutes
-- D: LAG cannot be used for sessionization
Your prediction

The 30-minute boundary must be defined precisely

Consider two events exactly 30 minutes apart. event_at - previous_event_at > INTERVAL '30 minutes' keeps exactly 30 minutes in the same session, while >= INTERVAL '30 minutes' starts a new one. Neither rule is automatically correct; the product definition must decide. A session metric should document the timeout and whether a new session starts on > or >= the boundary. Boundary rules are part of the metric.
05Session grain

Summarizing sessions

Once every event has user_id and session_number, ordinary aggregation turns event rows into session rows. With the sessionized data in a CTE:

Query
SELECT
    user_id,
    session_number,
    MIN(event_at) AS session_start,
    MAX(event_at) AS session_end,
    MAX(event_at) - MIN(event_at) AS session_duration,
    COUNT(*) AS event_count
FROM sessionized_events
GROUP BY user_id, session_number
ORDER BY user_id, session_start;
One row per session
user_idsessionsession_startsession_endevent_count
1109:0009:123
1210:0510:102
2111:0011:254
2214:0014:001

Now one row is one session instead of one event. Session duration is MAX(event_at) - MIN(event_at); for user 2 session 1, that is 11:25 - 11:00 = 25 minutes.

Gotcha — one-event sessions have zero duration
One-event sessions have zero observed duration. User 2 session 2 contains only 14:00 app_open, so start equals end and duration is 0. That does not mean the user spent zero seconds in the product; it means the observed time between the first and last recorded events is zero. MAX(event_at) - MIN(event_at) measures observed event span, not necessarily true attention time, and metric names should reflect that.
Key idea
Calculate session metrics at session grain. Do not accidentally average individual event rows when the denominator is supposed to be sessions. Common session metrics include session count, events per session, start and end, observed duration, and conversion sessions such as MAX(CASE WHEN event_name = 'purchase' THEN 1 ELSE 0 END).
06Sequences within a session

Entry, exit, position, and transitions

Rank events within each session to find the entry event (ROW_NUMBER() OVER (PARTITION BY user_id, session_number ORDER BY event_at, event_id), keep rn = 1) and the exit event (reverse the ordering to event_at DESC, event_id DESC, keep rn = 1). For our sample the entry event is app_open in every session, and the exit events are add_to_cart, search, purchase, and app_open. In web analytics the same pattern finds the landing page or the last recorded page. Note that “exit event” means the last event we observed, not proof the user intentionally left afterward.

Numbering every event gives the explicit journey. For user 2 session 1:

Query
SELECT
    user_id, session_number, event_at, event_name,
    ROW_NUMBER() OVER (
        PARTITION BY user_id, session_number
        ORDER BY event_at, event_id
    ) AS event_position
FROM sessionized_events
ORDER BY user_id, session_number, event_position;
User 2, session 1
event_positionevent_name
1app_open
2view_product
3checkout_started
4purchase

Comparing consecutive events

LAG(event_name) returns the previous action and LEAD(event_name) the next, both partitioned by user_id, session_number. This gives transitions such as app_open -> view_product and view_product -> checkout_started. Grouping current and next event pairs with COUNT(*) reveals the most common transitions:

Query
WITH transitions AS (
    SELECT
        event_name AS current_event,
        LEAD(event_name) OVER (
            PARTITION BY user_id, session_number
            ORDER BY event_at, event_id
        ) AS next_event
    FROM sessionized_events
)
SELECT
    current_event,
    next_event,
    COUNT(*) AS transition_count
FROM transitions
WHERE next_event IS NOT NULL
GROUP BY current_event, next_event
ORDER BY transition_count DESC, current_event, next_event;
Key idea
A funnel starts with a predefined sequence and asks how many users progressed through required stages; event-sequence analysis starts from observed activity and asks what paths actually occurred. Funnel analysis is expected-journey-first; event-sequence analysis is observed-journey-first. Both are useful and answer different questions.
07Ordering and data quality

When the event order cannot be trusted

Repeated events matter. Three consecutive view_product rows may be three real page views (“how many product pages did the user browse?”) or noise to collapse (“what sequence of distinct state changes occurred?”). Use LAG(event_name) to detect when event_name = previous_event and optionally remove repeated adjacent states, but only if repeated actions are not meaningful. That is different from duplicate events: two rows with the same event_id are a data-quality duplicate, while the same event name at different times and IDs may be two genuine actions. Deduplicate by the actual event identity, not by matching names, and sessionize on clean data.

The same timestamp can hide an undefined sequence. If add_to_cart and checkout_started both occur at 11:05:00 and you order only by event_at, their order is unresolved, which matters for transition analysis. Use a reliable secondary key (event_at, event_id, a source sequence number, or an event version). If event order changes the meaning, timestamp alone may not be enough.

Event systems distinguish event time from arrival or ingestion time. If Event A happened at 10:00 and Event B at 10:05 but B arrives first due to network delay, ordering by ingestion time yields B, A even though the user experienced A, B. For behavioural analysis, order by the timestamp that answers “when did the user action happen?”, and validate event-time quality in the pipeline.

Sessions can cross midnight, and are not daily activity

A 23:55 view_product and a 00:05 add_to_cart are only ten minutes apart, so under a 30-minute rule they belong to the same session even though the calendar date changed. Do not define a session as one user plus one calendar day unless that is the business rule; boundaries come from the inactivity condition, not automatically from midnight. Sessionization is also a different grain from daily activity: one row per user per day suits daily retention and streaks, while one row per user session suits visits, duration, entry/exit, and behaviour sequences. One customer can have three sessions in the same calendar day, so do not substitute one grain for the other.

08Recognition cues

How to spot sessionization problems

Think about sessionization and event-sequence analysis when the requirement says user sessions, visits, inactivity timeout, session duration, events per session, entry or exit event, landing page, event path, previous or next action, common transitions, journey sequence, or behaviour before purchase.

Watch for these common mistakes:

  • PARTITION BY is missing and events cross users.
  • Sessions are split at midnight even though inactivity is the real rule.
  • Exactly 30 minutes is handled differently from the business definition.
  • Event order relies on timestamps that can tie.
  • Ingestion time is used when event time should define the sequence.
  • Duplicate events inflate event counts.
  • Repeated legitimate actions are removed as if they were duplicates.
  • One-event sessions are interpreted as zero user attention.
  • Session metrics are calculated at event grain.
  • Events from separate sessions are connected into one transition sequence.
09A practical mental model

Answer five questions for sessionization

1 · What entity owns the session?

For example user_id, which becomes PARTITION BY user_id.

2 · What defines event order?

ORDER BY event_at, event_id, using enough to make the sequence deterministic.

3 · What creates a new session?

More than 30 minutes since the previous event, as a CASE returning 1 or 0.

4 · How do we assign session IDs?

A cumulative SUM(new_session) OVER (...) of the session-start flag.

5 · What should one final row represent?

One event (with a session ID), one user + session (metrics), or one adjacent pair (transitions).

Order events within the entity
      v
Compare each event with the previous event
      v
Mark inactivity breaks
      v
Cumulatively count the breaks
      v
Assign each event to a session
      v
Analyze each session or its internal sequence
10Check your understanding

One more prediction

These events come from one user. The rule: start a new session when more than 30 minutes pass since the previous event. How many sessions are there?

One user
event_atevent_name
09:00app_open
09:10view_product
09:35add_to_cart
10:06checkout_started
ONE MORE PREDICTIONHow many sessions are there?
Query
Gaps:
09:00 -> 09:10   10 minutes
09:10 -> 09:35   25 minutes
09:35 -> 10:06   31 minutes

Rule: new session when gap > 30 minutes

-- A: 1    B: 2    C: 3    D: 4
Your prediction
11Summary

Summary

Sessionization turns individual events into journeys. Define the entity (PARTITION BY user_id), define event order (ORDER BY event_at, event_id), use LAG(event_at) to find the previous event, mark a new session when the inactivity rule is met (previous IS NULL or gap > INTERVAL '30 minutes'), then cumulatively count the breaks with SUM(new_session) OVER (...). Once every event has a session ID, you can derive session start and end, observed duration, event count, entry and exit events, event position, previous and next events, common transitions, and conversion within a session.

Key idea
A session is defined by a sequence and a boundary rule, not simply by the date on which events occurred. And for event-sequence analysis, previous and next actions are meaningful only when the event order itself is trustworthy. Get the entity right, get the event ordering right, and define the inactivity boundary precisely; then sessionization turns an event stream into journeys that SQL can analyze.

Go deeper

Ask Colearn about this pattern

Not sure why your sessions connect different users, or how to derive entry, exit, and transitions? Ask about the concept, or paste a simplified version of your query.

Ask Colearn

3 of 3 free questions left

Instant answers, grounded in the same verified material the diagnostic grades against.

How do I turn timestamped events into sessions with IDs?

Order events within the entity, use LAG to find the previous event time, flag a new session when the inactivity gap exceeds the timeout (or there is no previous event), then take a running SUM of that flag as the session number.

The real session identity is user_id + session_number, because session 1 exists for every user.

from the unit →
Does exactly 30 minutes start a new session?

SQL cannot decide. '> 30 minutes' keeps exactly 30 minutes in the same session; '>= 30 minutes' starts a new one. Neither is automatically correct.

Boundary rules are part of the metric, so the product definition must state whether inactivity of exactly the timeout begins a new session.

from the unit →
How do I compute session start, end, duration, and event count?

Once each event has user_id + session_number, GROUP BY them and use MIN(event_at) as start, MAX(event_at) as end, MAX - MIN as observed duration, and COUNT(*) as event count.

Calculate session metrics at session grain; do not average individual event rows when the denominator should be sessions. A one-event session has zero observed duration, which is observed event span, not true attention time.

from the unit →
How do I find the previous or next action in a session?

Within PARTITION BY user_id, session_number ORDER BY event_at, event_id, LAG(event_name) returns the previous action and LEAD(event_name) the next, which gives you transitions like view_product -> add_to_cart.

Grouping those current/next pairs with COUNT(*) reveals the most common transitions, which is more flexible than a fixed funnel when you want observed behaviour.

from the unit →

That’s the free taste

Two ways to go deeper.

Don’t paste credentials, personal data, or confidential production records.

Related patterns

P19Pattern · 13 minGaps, islands & streaksThe same break-flag plus cumulative-sum pattern that builds sessions from gaps.P17Pattern · 12 minLAG, LEAD & value functionsThe previous and next event lookups behind session transitions.P22Pattern · 12 minFunnels & conversionExpected-journey analysis, the counterpart to observed event sequences.
Next step · Free diagnostic
Ready to keep going?

Take the free diagnostic to find your strengths, identify your gaps, and know exactly what to learn next.

Check my SQL fundamentalsBrowse all patterns