// case studies · real world impact

Real World SQL

Explore how companies solve business‑critical problems with SQL – full schema, solution, and insight.

Clickstream Sessionization & Next-Action Path Analysis

🏢 Spotify

Sessionize a raw clickstream event log using a 30-minute inactivity gap, then find the most common next action after each event type.

Business Problem

Spotify's product analytics team suspects users are getting frustrated somewhere in the listening experience but the raw event log has no session boundaries — just a stream of timestamped events per user. They need the events grouped into sessions (a new session starts whenever a user is inactive for more than 30 minutes), and then, for every event type, the single most common next action a user takes immediately after it, so the product team can see patterns like 'users skip right after playing a track' and prioritize fixing recommendation relevance over other UX issues. Raw clickstream and event-log data almost never comes pre-segmented into sessions, so sessionization — grouping a user's events based on a gap-based inactivity threshold — is a foundational technique in product and behavioral analytics. Once sessions are defined, analyzing what action typically follows another (a 'next-event' or bigram transition analysis) reveals friction points and usage patterns that a simple event-count report would completely miss.

Dataset & Schema

A single user_events table with event_id, user_id, event_type (e.g. app_open, play_track, skip_track, pause_track, like_track, search, browse_playlist), and event_timestamp.

CREATE TABLE user_events (
  event_id INTEGER PRIMARY KEY,
  user_id INTEGER,
  event_type TEXT,
  event_timestamp TIMESTAMP
);

SQL Solution

WITH ordered_events AS (
  SELECT
    user_id,
    event_type,
    event_timestamp,
    LAG(event_timestamp) OVER (PARTITION BY user_id ORDER BY event_timestamp) AS prev_timestamp
  FROM user_events
),
session_breaks AS (
  SELECT
    *,
    CASE
      WHEN prev_timestamp IS NULL THEN 1
      WHEN EXTRACT(EPOCH FROM (event_timestamp - prev_timestamp)) / 60.0 > 30 THEN 1
      ELSE 0
    END AS is_new_session
  FROM ordered_events
),
sessions AS (
  SELECT
    *,
    SUM(is_new_session) OVER (PARTITION BY user_id ORDER BY event_timestamp
      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS session_num
  FROM session_breaks
),
next_event AS (
  SELECT
    user_id,
    session_num,
    event_type AS current_event,
    LEAD(event_type) OVER (PARTITION BY user_id, session_num ORDER BY event_timestamp) AS next_event
  FROM sessions
),
transitions AS (
  SELECT
    current_event,
    next_event,
    COUNT(*) AS transition_count
  FROM next_event
  WHERE next_event IS NOT NULL
  GROUP BY current_event, next_event
)
SELECT
  current_event,
  next_event,
  transition_count,
  RANK() OVER (ORDER BY transition_count DESC) AS popularity_rank
FROM transitions
ORDER BY transition_count DESC, current_event;

Explanation

Step-by-step Solution:

1

ordered_events uses LAG() to attach each event's previous event timestamp within the same user's event stream.

2

session_breaks flags a new session (is_new_session = 1) whenever there's no previous event or the gap since the previous event exceeds 30 minutes, using EXTRACT(EPOCH ...) to convert the timestamp difference into minutes.

3

sessions runs a running SUM() of is_new_session as a window function ordered by time, which produces a stable, incrementing session_num for every user — this is the standard gap-and-reset sessionization pattern.

4

next_event uses LEAD() partitioned by user_id and session_num to look ahead to the next event within the same session (never crossing a session boundary), pairing every event with whatever the user did right after it.

5

The final query counts how often each (current_event, next_event) pair occurs and ranks the transitions by frequency, surfacing the dominant behavioral pattern the product team can act on.