Event Modeling — Learn Funnels, Session IDs, Retention, and Recursive CTEs in Practice
AdvancedEvent ModelingFunnel AnalysisSession IDsCohort RetentionRecursive CTEsPostgreSQL Compatible5 questions
QUESTION 1
Funnel Analysis — Calculate sequential conversion rates with MIN(...) FILTER and ordering comparisons
MIN FILTERNULLIFFunnel AnalysisConversion Rates
Background

A funnel is a core event-analysis tool that measures the number of users who pass through multiple steps in sequence, such as view → cart → purchase. A standard pattern is to extract each user's first timestamp for each event with MIN(event_time) FILTER (WHERE event_type='...'), then determine step completion by comparing the timestamps.

-- Get the first occurrence time of each event type per user
MIN(event_time) FILTER (WHERE event_type = 'view')     AS t_view
MIN(event_time) FILTER (WHERE event_type = 'cart')     AS t_cart
MIN(event_time) FILTER (WHERE event_type = 'purchase') AS t_purchase

-- Ordering comparison: confirm that cart happened after view
WHERE t_cart > t_view AND t_purchase > t_cart
Comparisons involving NULL are automatically treated as false:NULL > any value returns NULL, and COUNT(*) FILTER excludes rows whose filter condition is NULL. Thus, a user who never triggered a cart event (t_cart is NULL) is automatically excluded from the step-2 count. SQL's three-valued logic means you do not need to write an explicit IS NOT NULL check.
Problem

From the EC site's user_events table, aggregate a sequential funnel. (1) Determine whether each user completed view → cart → purchase in that order, (2) count the total number of users completing each step, and (3) calculate the view→cart and cart→purchase conversion rates as percentages rounded to one decimal place. Return one row with five columns.

Table used
► user_events (11 rows / 5 users)
user_idevent_typeevent_time
1view2024-01-10 10:00
1cart2024-01-10 10:05
1purchase2024-01-10 10:30
2view2024-01-10 11:00
2cart2024-01-10 11:15
2purchase2024-01-10 11:45
3view2024-01-10 12:00
3cart2024-01-10 12:30
4view2024-01-10 14:00
5view2024-01-10 15:00
5purchase2024-01-10 15:30

※ user5 went directly from view to purchase and skipped cart. In a sequential funnel, user5 does not complete the cart step because t_cart is NULL.

Expected Output

Expected output (one row, five columns):

step1_viewstep2_cartstep3_purchaseview_to_cart_pctcart_to_purchase_pct
53260.066.7

step1=5 (everyone completed view), step2=3 (users 1, 2, and 3 completed view→cart), and step3=2 (users 1 and 2 completed view→cart→purchase). user5 skipped cart and is excluded from both steps 2 and 3.

QUESTION 2
Assign Session IDs — Generate cumulative session numbers with LAG + SUM() OVER (Gaps & Islands)
SUM OVERROWS UNBOUNDEDSession IDsGaps & Islands
Background

After detecting session boundaries (covered in Basic Q3), the next practical step is assigning session IDs. If a “session-start flag” is cumulatively summed with SUM() OVER (... ROWS UNBOUNDED PRECEDING), every row within a session receives the same number. This is a representative application of SQL's Gaps & Islands pattern.

-- Generate cumulative session numbers
SUM(is_new_session) OVER (
  PARTITION BY user_id
  ORDER BY     event_time
  ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS session_num

-- If is_new_session is [1,0,1,0], the cumulative sum is [1,1,2,2]
--   Rows in the same session receive the same number
What ROWS UNBOUNDED PRECEDING means: It sets the frame from the beginning through the current row. SUM(is_new_session) OVER (... ORDER BY ...) also defaults to UNBOUNDED PRECEDING through CURRENT ROW (technically a RANGE frame), but stating ROWS explicitly is safer and makes the intent clear. Understanding this frame specification is key to mastering window aggregation.
Problem

For the user_events table, treat an event gap of 30 minutes or more as a new-session boundary and assign each event row a session ID in the form user_id-session_num. Return user_id, session_id, event_type, event_time, ordered by user_id and event_time ascending.

Table used
► user_events (8 rows / 3 users)
user_idevent_typeevent_time
1view2024-01-10 10:00
1click2024-01-10 10:05
1view2024-01-10 10:50
1purchase2024-01-10 10:55
2view2024-01-10 11:00
2click2024-01-10 11:10
2view2024-01-10 12:00
3view2024-01-10 09:00

※ user1: 10:05→10:50 is 45 minutes (new session). user2: 11:10→12:00 is 50 minutes (new session). user3 has only one event.

Expected Output

Expected output (ascending user_id / event_time):

user_idsession_idevent_typeevent_time
11-1view10:00
11-1click10:05
11-2view10:50
11-2purchase10:55
22-1view11:00
22-1click11:10
22-2view12:00
33-1view09:00

user1 receives 2 sessions, user2 receives 2 sessions, and user3 receives 1 session. Rows within the same session share the same ID.

QUESTION 3
Cohort Retention — Calculate N-month survival rates by signup month with JOIN + DATE_TRUNC
DATE_TRUNCFIRST_VALUE OVERCohort AnalysisRetention Rate
Background

Cohort analysis tracks a group of users who share the same first-use month (signup month) and measures how many remain active over time. Retention, one of the most important metrics for subscription products such as SaaS, mobile apps, and ecommerce, is calculated with this pattern.

-- Determine each user's cohort (first-use month)
SELECT user_id, DATE_TRUNC('month', MIN(event_time))::date AS cohort_month
FROM   user_events GROUP BY user_id

-- Monthly retention for each cohort
SELECT
  cohort_month,
  months_since_signup,
  active_users,
  FIRST_VALUE(active_users) OVER (
    PARTITION BY cohort_month ORDER BY months_since_signup
  ) AS cohort_size              -- initial size of each cohort
The essence is aggregation across two axes (cohort × time): The January signup cohort has counts for January, February, and March; the February cohort has counts for February and March. The result is a two-dimensional cohort table. A heatmap makes it easy to see which user-acquisition efforts succeeded in a given month. FIRST_VALUE() supplies the initial cohort size so relative percentages can be compared.
Problem

From the user_events table, determine each user's first-use month (cohort), then calculate monthly active-user counts and retention rates (%) by cohort. Return cohort_month, months_since_signup, active_users, cohort_size, retention_pct, ordered by cohort_month and months_since_signup ascending.

Table used
► user_events (10 rows / 6 users / 3 months)
user_idevent_time
12024-01-15
12024-02-10
12024-03-05
22024-01-20
22024-02-15
32024-01-25
42024-02-05
42024-03-12
52024-02-20
62024-03-15

※ January cohort: users 1, 2, 3 (3 users) → February: users 1 and 2 remain (2/3=66.7%) → March: only user1 remains (1/3=33.3%). February cohort: users 4 and 5 (2 users) → only user4 remains in March (50%).

Expected Output

Expected output (ascending cohort_month / months_since_signup):

cohort_monthmonths_since_signupactive_userscohort_sizeretention_pct
2024-01-01033100.0
2024-01-0112366.7
2024-01-0121333.3
2024-02-01022100.0
2024-02-0111250.0
2024-03-01011100.0

The January cohort retains 33% after two months. The February cohort retains 50% after one month. This measures early cohort retention for the product.

QUESTION 4
Behavioral-Path Aggregation — Extract common user paths with STRING_AGG
STRING_AGGARRAY_AGGTwo-stage aggregationPath Analysis
Background

In marketing and UX improvement, the path showing “which events a user passed through, and in what order” is extremely valuable. For example, view → cart → purchase and view → purchase have very different contexts even though both end in purchase.

This question teaches two-stage aggregation with STRING_AGG (or ARRAY_AGG). First, turn each user's events into a time-ordered string; second, count users who share the same path. This extracts the Top N most common behavioral patterns.

-- Concatenate events in chronological order for each user
STRING_AGG(event_type, ' → ' ORDER BY event_time)
Why specify ORDER BY inside STRING_AGG: Putting ORDER BY inside the aggregate guarantees the order of the concatenated string. If you omit it, the order may change between executions.
Problem

Write a query that concatenates each user's events in firing order with “ → ”, then aggregates the user count and path length for each identical path.
First generate one path string per user by concatenating events chronologically, then count users per path. Sort by descending user count; ties are sorted by descending path length.

Table used
► events (12 rows / 5 users)
user_idevent_typeevent_time
1view2024-01-15 10:00:00
1cart2024-01-15 10:05:00
1purchase2024-01-15 10:10:00
2view2024-01-15 11:00:00
2cart2024-01-15 11:03:00
2purchase2024-01-15 11:08:00
3view2024-01-15 12:00:00
3cart2024-01-15 12:02:00
4view2024-01-15 13:00:00
4purchase2024-01-15 13:01:00
5view2024-01-15 14:00:00
5purchase2024-01-15 14:05:00
Expected Output

Expected output (descending user count; ties by descending path length):

pathuser_countpath_length
view → cart → purchase23
view → purchase22
view → cart12

※ user1 and user2 fully converted (view → cart → purchase); user4 and user5 purchased after skipping cart (view → purchase); user3 dropped off partway through (view → cart).

QUESTION 5
Fill Missing Dates with a Recursive CTE — Visualize days without events as 0
WITH RECURSIVELEFT JOINCOALESCEDate Series
Background

When charting daily active users (DAU), a day with zero events may be absent from the data, causing a line chart to break unnaturally. The standard solution is to generate a date-series table containing every day in the analysis period and LEFT JOIN the real data to it.

This question dynamically generates a date series with WITH RECURSIVE. Recursive CTEs are difficult to grasp, so the explanation visualizes three things at every step: the current accumulated result, the row just added, and the next row to generate.

-- Basic WITH RECURSIVE structure
WITH RECURSIVE date_series AS (
  SELECT DATE '2024-01-10' AS day          -- base case (first row)
  UNION ALL
  SELECT day + INTERVAL '1 day'            -- recursive term (+1 day from the previous row)
  FROM date_series
  WHERE day < DATE '2024-01-15'            -- termination condition
)
How recursion proceeds: The base case runs first, then the recursive term is evaluated with its result as input. As long as a new result is returned, the recursive term repeats for the row just added. Without a WHERE termination condition, the query loops forever.
Problem

For the six-day period from 2024-01-10 through 2024-01-15 (inclusive), aggregate daily DAU (distinct users) and output days with no events as 0.
Because aggregating only the events table drops empty days, generate a continuous date series with a recursive CTE or similar technique, then join it to the aggregate to fill missing dates.

Table used
► events (6 rows / 4 days)
user_idevent_time
12024-01-10 09:00
22024-01-10 14:00
12024-01-12 10:00
32024-01-12 16:00
22024-01-14 11:00
12024-01-15 09:30

※ No one was active on 2024-01-11 or 2024-01-13, so these dates do not exist in the events table.

Expected Output

Expected output (ascending day):

dayactive_users
2024-01-102
2024-01-110
2024-01-122
2024-01-130
2024-01-141
2024-01-151

※ 01-11 and 01-13, which are absent from events, are filled with 0.