Sessionization — Get the previous event time with LAG and assign session IDs with a conditional cumulative SUM over gaps longer than 30 minutes
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)
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.
| user_id | event_time |
|---|---|
| 1 | 2024-01-10 10:00 |
| 1 | 2024-01-10 10:05 |
| 1 | 2024-01-10 10:20 |
| 1 | 2024-01-10 12:00 |
| 1 | 2024-01-10 12:10 |
| 2 | 2024-01-10 09:00 |
| 2 | 2024-01-10 09:15 |
| 2 | 2024-01-10 11:30 |
| 3 | 2024-01-11 14:00 |
| user_id | session_count | total_events | avg_events_per_session |
|---|---|---|---|
| 1 | 2 | 5 | 2.5 |
| 2 | 2 | 3 | 1.5 |
| 3 | 1 | 1 | 1.0 |
Attribution — Extract the first-touch channel and the touch immediately before purchase with FIRST_VALUE + LAG
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)
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.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.
| user_id | event_time | event_type | channel |
|---|---|---|---|
| 1 | 2024-01-10 10:00 | view | google_ads |
| 1 | 2024-01-10 11:00 | view | organic |
| 1 | 2024-01-10 12:00 | click | facebook_ads |
| 1 | 2024-01-10 13:00 | purchase | direct |
| 2 | 2024-01-11 09:00 | view | organic |
| 2 | 2024-01-11 10:00 | purchase | |
| 3 | 2024-01-11 11:00 | view | google_ads |
| 3 | 2024-01-11 12:00 | click | google_ads |
| 4 | 2024-01-12 09:00 | view | twitter_ads |
| user_id | first_touch | last_touch | purchase_time |
|---|---|---|---|
| 1 | google_ads | facebook_ads | 2024-01-10 13:00 |
| 2 | organic | organic | 2024-01-11 10:00 |
Time Between Funnel Steps — Calculate average conversion time with LAG + EXTRACT(EPOCH)
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)
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.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.
| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 2024-01-10 10:00 |
| 1 | click | 2024-01-10 10:10 |
| 1 | purchase | 2024-01-10 11:00 |
| 2 | view | 2024-01-11 09:00 |
| 2 | click | 2024-01-11 09:30 |
| 3 | view | 2024-01-11 14:00 |
| 3 | click | 2024-01-11 14:05 |
| 3 | purchase | 2024-01-11 14:35 |
| transition | event_count | avg_minutes |
|---|---|---|
| view -> click | 3 | 15.0 |
| click -> purchase | 2 | 40.0 |
Recursive CTE — Generate a date dimension and zero-fill a DAU time series with LEFT JOIN
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) )
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.
| user_id | event_time |
|---|---|
| 1 | 2024-01-10 |
| 2 | 2024-01-10 |
| 1 | 2024-01-12 |
| 3 | 2024-01-12 |
| 1 | 2024-01-14 |
| event_date | dau |
|---|---|
| 2024-01-10 | 2 |
| 2024-01-11 | 0 |
| 2024-01-12 | 2 |
| 2024-01-13 | 0 |
| 2024-01-14 | 1 |
Consecutive Login Days — Find the longest streak by grouping on the date − ROW_NUMBER difference with the Gaps and Islands pattern
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
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.
| user_id | login_date |
|---|---|
| 1 | 2024-01-01 |
| 1 | 2024-01-02 |
| 1 | 2024-01-03 |
| 1 | 2024-01-05 |
| 1 | 2024-01-06 |
| 2 | 2024-01-10 |
| 3 | 2024-01-15 |
| 3 | 2024-01-16 |
| 3 | 2024-01-17 |
| 3 | 2024-01-18 |
| user_id | longest_streak |
|---|---|
| 1 | 3 |
| 2 | 1 |
| 3 | 4 |