SQL Event Modeling — Applied Attribution Analysis

ADVEvent ModelingSessionizationAttributionWindow Functions / Recursive CTEsGaps and IslandsPostgreSQL Compatible5 questions
1 / 5 · In progress 0 / 5 completed
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
Expected Output
user_idsession_counttotal_eventsavg_events_per_session
1252.5
2231.5
3111.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
Expected Output
user_idfirst_touchlast_touchpurchase_time
1google_adsfacebook_ads2024-01-10 13:00
2organicorganic2024-01-11 10:00
QUESTION 8

Time Between Funnel Steps — Calculate average conversion time with LAG + EXTRACT(EPOCH)

LAGEXTRACT EPOCHFunnel AnalysisElapsed Time
Background

The basic set 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
Expected Output
transitionevent_countavg_minutes
view -> click315.0
click -> purchase240.0
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
Expected Output
event_datedau
2024-01-102
2024-01-110
2024-01-122
2024-01-130
2024-01-141
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
Expected Output
user_idlongest_streak
13
21
34