Event Modeling — Learn Sessionization, Attribution, Gaps and Islands, and Recursive CTEs in Practice
AdvancedEvent ModelingSessionizationAttributionWindow Functions / Recursive CTEsGaps and IslandsPostgreSQL Compatible5 questions
QUESTION 6
Sessionization — Get the previous event time with LAG and assign session IDs with a conditional cumulative SUM over gaps longer than 30 minutes
LAGSUM OVERSessionizationWindow Functions
Background

One of the most common preprocessing tasks in event modeling is sessionization. It groups consecutive events from the same user into sessions, using a fixed period of inactivity (30 minutes, for example) as the boundary. Get the preceding event time with the window function LAG, then cumulatively sum a 0/1 gap flag to assign session IDs.

-- Get the value one row earlier within the same PARTITION
LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time)

-- Assign session IDs with a cumulative SUM (cumulative boundary flags = session number)
SUM(is_new_session) OVER (PARTITION BY user_id ORDER BY event_time)
The three-stage sessionization pattern: (1) get the previous time with LAG → (2) turn the gap test into a 0/1 flag → (3) convert it to a session ID with a cumulative SUM. This three-CTE pattern is the fundamental workhorse of practical event analysis, and it is the starting point for session-length, page-transition, and bounce-rate analysis.
Problem

From the user_events table, start a new session when the gap from the same user's previous event is longer than 30 minutes, then aggregate the session count, total event count, and average events per session for each user. Return user_id, session_count, total_events, avg_events_per_session (one decimal place), ordered by user_id ascending.

Table used
► user_events (9 rows)
user_idevent_time
12024-01-10 10:00
12024-01-10 10:05
12024-01-10 10:20
12024-01-10 12:00
12024-01-10 12:10
22024-01-10 09:00
22024-01-10 09:15
22024-01-10 11:30
32024-01-11 14:00

※ user1: the gap from 10:20 to 12:00 is 100 minutes → a new session. user2: the gap from 09:15 to 11:30 is 135 minutes → a new session.

Expected Output

Expected output (ordered by user_id):

user_idsession_counttotal_eventsavg_events_per_session
1252.5
2231.5
3111.0

user1: 5 events ÷ 2 sessions = 2.5. user2: 3 ÷ 2 = 1.5. user3: 1 ÷ 1 = 1.0.

QUESTION 7
Attribution — Extract the first-touch channel and the touch immediately before purchase with FIRST_VALUE + LAG
FIRST_VALUELAGAttributionMarketing Analysis
Background

Attribution is at the heart of marketing analysis. It assigns a conversion (a purchase) to the channels that contributed to it, using models such as first touch, last touch, and multi-touch. The basic technique is to attach an always-available first value and the preceding value to each user's behavioral history with window functions.

-- FIRST_VALUE: propagate the first row's value within a PARTITION to every row
FIRST_VALUE(channel) OVER (PARTITION BY user_id ORDER BY event_time)

-- LAG: get the previous row's value (used for the touch immediately before purchase)
LAG(channel) OVER (PARTITION BY user_id ORDER BY event_time)
Watch FIRST_VALUE's default frame: In PostgreSQL, specifying ORDER BY for a window function gives the default frame RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. FIRST_VALUE returns the first value in the frame, so with ORDER BY it propagates the partition's first value to every row, which is exactly what first-touch extraction needs.
Problem

From the user_events table, extract for each purchase event the user's first-touch channel (first_touch) and the channel immediately before purchase (last_touch). Return user_id, first_touch, last_touch, purchase_time, ordered by user_id ascending. Exclude users with no purchase.

Table used
► user_events (9 rows)
user_idevent_timeevent_typechannel
12024-01-10 10:00viewgoogle_ads
12024-01-10 11:00vieworganic
12024-01-10 12:00clickfacebook_ads
12024-01-10 13:00purchasedirect
22024-01-11 09:00vieworganic
22024-01-11 10:00purchaseemail
32024-01-11 11:00viewgoogle_ads
32024-01-11 12:00clickgoogle_ads
42024-01-12 09:00viewtwitter_ads

※ user3 and user4 did not reach purchase and are excluded.

Expected Output

Expected output (ordered by user_id):

user_idfirst_touchlast_touchpurchase_time
1google_adsfacebook_ads2024-01-10 13:00
2organicorganic2024-01-11 10:00

user1: first = google_ads, previous = facebook_ads (the click row). user2: both first and previous are organic (view → direct purchase).

QUESTION 8
Time Between Funnel Steps — Calculate average conversion time with LAG + EXTRACT(EPOCH)
LAGEXTRACT EPOCHFunnel AnalysisElapsed Time
Background

Basic Q6 and Q7 covered funnel reach and order validation, but in practice, time between steps is also an important measure of conversion quality. If the average view → click time is too long, users may lack decision-making information; if click → purchase takes too long, the checkout flow may have friction.

-- Get the time difference from the previous event in seconds
EXTRACT(EPOCH FROM (event_time - prev_event_time))

-- Divide by 60 for minutes, then ROUND to one decimal place
ROUND(AVG(...) / 60, 1)
EXTRACT(EPOCH FROM interval) converts an interval to seconds: timestamp - timestamp returns an interval, and extracting EPOCH gives a numeric value in seconds. This serves the same role as MySQL's TIMESTAMPDIFF(SECOND, t1, t2) and BigQuery's TIMESTAMP_DIFF(t2, t1, SECOND). Convert seconds with /60 → minutes or /3600 → hours.
Problem

From the user_events table, calculate the elapsed time for each consecutive funnel event (view → click and click → purchase), then output the count and average minutes for each transition type. Return transition, event_count, avg_minutes (one decimal place), in view → click and click → purchase order.

Table used
► user_events (8 rows)
user_idevent_typeevent_time
1view2024-01-10 10:00
1click2024-01-10 10:10
1purchase2024-01-10 11:00
2view2024-01-11 09:00
2click2024-01-11 09:30
3view2024-01-11 14:00
3click2024-01-11 14:05
3purchase2024-01-11 14:35

※ user1: 10 minutes → 50 minutes; user2: 30 minutes; user3: 5 minutes → 30 minutes.

Expected Output

Expected output (transition order):

transitionevent_countavg_minutes
view -> click315.0
click -> purchase240.0

view → click average: (10+30+5) ÷ 3 = 15.0 minutes. click → purchase average: (50+30) ÷ 2 = 40.0 minutes.

QUESTION 9
Recursive CTE — Generate a date dimension and zero-fill a DAU time series with LEFT JOIN
WITH RECURSIVELEFT JOINTime-Series AnalysisRecursive CTE
Background

The biggest trap when visualizing a DAU (Daily Active Users) time series is that days with no events disappear from the result. A straightforward GROUP BY date produces no row for a day with zero activity, so the graph is not continuous and can give a misleading impression. Generate a date dimension with a recursive CTE to prevent this.

WITH RECURSIVE date_series(d) AS (
  SELECT MIN(event_time::date) FROM user_events    -- Anchor
  UNION ALL
  SELECT d + 1                                  -- Recursive step: generate a row one day after each accumulated row
  FROM   date_series
  WHERE  d < (SELECT MAX(event_time::date) FROM user_events)
)
A recursive CTE is a three-part set: anchor, recursive member, and termination condition: (1) the anchor is the first row, (2) the recursive member generates rows added to the accumulated result on each iteration, and (3) the termination condition in WHERE stops recursion. The recursive member can reference the accumulated result (itself) and continues generating the next row until the condition becomes false. Here, recursion stops after reaching the date of the last event.
Problem

From the user_events table, output every date from the first event date through the last event date and calculate the DAU (number of active users) for each date. Output DAU=0 for dates with no events. Return event_date, dau, ordered by event_date ascending.

Table used
► user_events (5 rows)
user_idevent_time
12024-01-10
22024-01-10
12024-01-12
32024-01-12
12024-01-14

※ There are no events on 1/11 or 1/13. Each must still be output with DAU=0.

Expected Output

Expected output (ordered by event_date):

event_datedau
2024-01-102
2024-01-110
2024-01-122
2024-01-130
2024-01-141

Rows are generated even for the zero-event days 1/11 and 1/13. This is the effect of the date dimension × LEFT JOIN.

QUESTION 10
Consecutive Login Days — Find the longest streak by grouping on the date − ROW_NUMBER difference with the Gaps and Islands pattern
ROW_NUMBERGaps & IslandsStreak AnalysisRetention
Background

Detecting consecutive blocks (streaks), such as consecutive login or purchase days, is solved with the classic Gaps and Islands pattern. Assign ROW_NUMBER() to each row and calculate the curious difference date − row number: the value stays the same within a consecutive block and changes when a gap appears.

-- The magic key that identifies an island: date - row_number(day)
login_date - ROW_NUMBER() OVER (
  PARTITION BY user_id ORDER BY login_date
) * INTERVAL '1 day' AS streak_group
Why date − row_number reveals islands: In consecutive dates, both the date and row_number increase by one, so their difference does not change. When a gap appears, only the date jumps while row_number does not, so the difference increases. Rows with the same difference belong to the same island, or consecutive block.
Problem

From the user_logins table, calculate each user's longest consecutive login streak (longest_streak). Return user_id, longest_streak, ordered by user_id ascending.

Table used
► user_logins (10 rows)
user_idlogin_date
12024-01-01
12024-01-02
12024-01-03
12024-01-05
12024-01-06
22024-01-10
32024-01-15
32024-01-16
32024-01-17
32024-01-18

※ user1 has two streaks: 1/1–1/3 (3 days) and 1/5–1/6 (2 days). user3 has four consecutive days from 1/15–1/18.

Expected Output

Expected output (ordered by user_id):

user_idlongest_streak
13
21
34

user1's longest is the first three-day streak. user2 has only one day. user3's longest streak is four consecutive days.