Funnel Analysis — Calculate sequential conversion rates with MIN(...) FILTER and ordering comparisons
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
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.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.
| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 2024-01-10 10:00 |
| 1 | cart | 2024-01-10 10:05 |
| 1 | purchase | 2024-01-10 10:30 |
| 2 | view | 2024-01-10 11:00 |
| 2 | cart | 2024-01-10 11:15 |
| 2 | purchase | 2024-01-10 11:45 |
| 3 | view | 2024-01-10 12:00 |
| 3 | cart | 2024-01-10 12:30 |
| 4 | view | 2024-01-10 14:00 |
| 5 | view | 2024-01-10 15:00 |
| 5 | purchase | 2024-01-10 15:30 |
| step1_view | step2_cart | step3_purchase | view_to_cart_pct | cart_to_purchase_pct |
|---|---|---|---|---|
| 5 | 3 | 2 | 60.0 | 66.7 |
WITH user_first_steps AS ( -- FROM: read data from the user-events table -- GROUP BY: aggregate at the user level SELECT user_id, MIN(event_time) FILTER (WHERE event_type = 'view') AS t_view, -- MIN() FILTER: get the earliest time (MIN) satisfying the specified condition (event_type) for each user MIN(event_time) FILTER (WHERE event_type = 'cart') AS t_cart, MIN(event_time) FILTER (WHERE event_type = 'purchase') AS t_purchase FROM user_events GROUP BY user_id ) SELECT COUNT(*) FILTER (WHERE t_view IS NOT NULL) AS step1_view, -- count rows satisfying the condition (IS NOT NULL = has a value) COUNT(*) FILTER (WHERE t_cart > t_view) AS step2_cart, -- ordering comparison: check whether cart happened after view COUNT(*) FILTER (WHERE t_purchase > t_cart AND t_cart > t_view) AS step3_purchase, -- ordering comparison: also check whether purchase happened after cart ROUND(100.0 * COUNT(*) FILTER (WHERE t_cart > t_view) -- ROUND(): round to the specified precision (one decimal place here) / multiplying by 100.0 switches to fractional arithmetic and avoids integer division / NULLIF(value1, value2): return NULL when the values are equal; a standard way to prevent division by zero / NULLIF(COUNT(*) FILTER (WHERE t_view IS NOT NULL), 0), 1) AS view_to_cart_pct, ROUND(100.0 * COUNT(*) FILTER (WHERE t_purchase > t_cart AND t_cart > t_view) / NULLIF(COUNT(*) FILTER (WHERE t_cart > t_view), 0), 1) AS cart_to_purchase_pct FROM user_first_steps; /* Execution order (logical SQL evaluation order): 1. CTE user_first_steps → aggregate each user's first event timestamps 2. Outer query → conditionally count step completions and calculate conversion rates */
LEGEND
① FROM
FROM user_eventsThere are multiple event rows per user. Note that user5 went from view to purchase while skipping cart.| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 10:00 |
| 1 | cart | 10:05 |
| 1 | purchase | 10:30 |
| 2 | view | 11:00 |
| 2 | cart | 11:15 |
| 2 | purchase | 11:45 |
| 3 | view | 12:00 |
| 3 | cart | 12:30 |
| 4 | view | 14:00 |
| 5 | view | 15:00 |
| 5 | purchase | 15:30 |
t_cart > t_view returns NULL and COUNT(*) FILTER excludes that row. The query works correctly without an explicit IS NOT NULL check.x / NULLIF(0, 0) = x / NULL = NULL, avoiding an error. A conversion rate for a period with no data remains NULL rather than being misleadingly displayed as 0%, which is useful for downstream BI tools.COUNT(DISTINCT user_id) FILTER (WHERE event_type='cart'), users who carted without viewing first (for example, through an external link) are included. That measures cumulative achievers, not a sequential funnel. State the intended definition clearly in practice.3 / 5 = 0 in PostgreSQL integer division. Convert one side to fractional arithmetic with 100.0 *; ::numeric and * 1.0 have the same effect.t_cart > t_view AND t_cart <= t_view + INTERVAL '24 hours'. The same pattern recurs in retention analysis, campaign measurement, and advertising ROI calculation, making this MIN(...) FILTER plus ordering-comparison pattern a core event-analysis technique.Assign Session IDs — Generate cumulative session numbers with LAG + SUM() OVER (Gaps & Islands)
After detecting session boundaries (covered in the basic set), 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
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.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.
| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 2024-01-10 10:00 |
| 1 | click | 2024-01-10 10:05 |
| 1 | view | 2024-01-10 10:50 |
| 1 | purchase | 2024-01-10 10:55 |
| 2 | view | 2024-01-10 11:00 |
| 2 | click | 2024-01-10 11:10 |
| 2 | view | 2024-01-10 12:00 |
| 3 | view | 2024-01-10 09:00 |
| user_id | session_id | event_type | event_time |
|---|---|---|---|
| 1 | 1-1 | view | 10:00 |
| 1 | 1-1 | click | 10:05 |
| 1 | 1-2 | view | 10:50 |
| 1 | 1-2 | purchase | 10:55 |
| 2 | 2-1 | view | 11:00 |
| 2 | 2-1 | click | 11:10 |
| 2 | 2-2 | view | 12:00 |
| 3 | 3-1 | view | 09:00 |
WITH with_lag AS ( -- LAG() OVER: a window function that gets the previous row's value in the specified order (ORDER BY) -- PARTITION BY: calculate independently per user (never cross a user boundary) SELECT user_id, event_type, event_time, LAG(event_time) OVER ( PARTITION BY user_id ORDER BY event_time ) AS prev_event_time FROM user_events ), with_flag AS ( -- CASE WHEN ... THEN ... ELSE ... END: basic conditional syntax -- EXTRACT(EPOCH FROM ...): convert a time interval to a number of seconds (epoch seconds) SELECT user_id, event_type, event_time, CASE WHEN prev_event_time IS NULL OR EXTRACT(EPOCH FROM (event_time - prev_event_time)) / 60 >= 30 THEN 1 ELSE 0 END AS is_new_session FROM with_lag ), with_session_num AS ( -- SUM() OVER: calculate the cumulative sum of matching values (here, the session-start flag 1) -- ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: the frame from the first row through the current row SELECT user_id, event_type, event_time, SUM(is_new_session) OVER ( -- cumulative flag sum = session number PARTITION BY user_id ORDER BY event_time ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS session_num FROM with_flag ) SELECT user_id, user_id || '-' || session_num AS session_id, -- || operator: concatenate strings (for example, '1' || '-' || '1' -> '1-1') event_type, TO_CHAR(event_time, 'HH24:MI') AS event_time FROM with_session_num ORDER BY user_id, event_time; /* Execution order (logical SQL evaluation order): 1. CTE with_lag 2. CTE with_flag 3. CTE with_session_num 4. Outer query */
LEGEND
① FROM
FROM user_eventsEight event logs from three users. Group consecutive events using the rule “a gap of 30 minutes or more starts a new session,” then assign session IDs.| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 10:00 |
| 1 | click | 10:05 |
| 1 | view | 10:50 |
| 1 | purchase | 10:55 |
| 2 | view | 11:00 |
| 2 | click | 11:10 |
| 2 | view | 12:00 |
| 3 | view | 09:00 |
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW defines the frame by physical row count, while RANGE defines it by ORDER BY key values. If multiple rows share an event_time, ROWS processes them one at a time but RANGE processes all rows at that time together. Explicitly use ROWS for cumulative session numbers.SELECT * FROM with_flag. In practice, splitting CTEs is more maintainable than one enormous query.SUM(is_new_session) OVER (ORDER BY event_time), user1's cumulative sum carries into user2 and produces incorrect session numbers. Always specify PARTITION BY user_id to reset the cumulative sum per user.(user_id, session_num).SELECT session_id, COUNT(*) AS events, MAX(event_time) - MIN(event_time) AS duration FROM with_session_num GROUP BY session_id. Many standard metrics from Google Analytics and Mixpanel are internally built from this flow: detect session boundaries → assign session IDs → aggregate. Understanding one window-function pattern can open up the world of product analytics.Cohort Retention — Calculate N-month survival rates by signup month with JOIN + DATE_TRUNC
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
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.
| user_id | event_time |
|---|---|
| 1 | 2024-01-15 |
| 1 | 2024-02-10 |
| 1 | 2024-03-05 |
| 2 | 2024-01-20 |
| 2 | 2024-02-15 |
| 3 | 2024-01-25 |
| 4 | 2024-02-05 |
| 4 | 2024-03-12 |
| 5 | 2024-02-20 |
| 6 | 2024-03-15 |
| cohort_month | months_since_signup | active_users | cohort_size | retention_pct |
|---|---|---|---|---|
| 2024-01-01 | 0 | 3 | 3 | 100.0 |
| 2024-01-01 | 1 | 2 | 3 | 66.7 |
| 2024-01-01 | 2 | 1 | 3 | 33.3 |
| 2024-02-01 | 0 | 2 | 2 | 100.0 |
| 2024-02-01 | 1 | 1 | 2 | 50.0 |
| 2024-03-01 | 0 | 1 | 1 | 100.0 |
WITH user_cohort AS ( SELECT -- first month per user = cohort user_id, DATE_TRUNC('month', MIN(event_time))::date AS cohort_month -- truncate to month → DATE type FROM user_events GROUP BY user_id ), user_activity AS ( SELECT DISTINCT -- months when each user was active (deduplicated) user_id, DATE_TRUNC('month', event_time)::date AS activity_month FROM user_events ), cohort_join AS ( SELECT -- attach elapsed months to each activity row uc.cohort_month, ua.activity_month, ua.user_id, ((EXTRACT(YEAR FROM ua.activity_month) - EXTRACT(YEAR FROM uc.cohort_month)) * 12 -- months elapsed since signup (year difference × 12 + month difference) + (EXTRACT(MONTH FROM ua.activity_month) - EXTRACT(MONTH FROM uc.cohort_month)))::int AS months_since_signup FROM user_cohort uc -- USING (user_id): shorthand JOIN on the column shared by both tables JOIN user_activity ua USING (user_id) ) SELECT cohort_month, months_since_signup, COUNT(DISTINCT user_id) AS active_users, -- number of active users in each month FIRST_VALUE(COUNT(DISTINCT user_id)) OVER ( -- FIRST_VALUE() OVER: get the value in the first row of the specified order / the first value by elapsed month is the initial cohort size PARTITION BY cohort_month ORDER BY months_since_signup ) AS cohort_size, ROUND(100.0 * COUNT(DISTINCT user_id) -- calculate retention as active users / initial cohort size / FIRST_VALUE(COUNT(DISTINCT user_id)) OVER ( PARTITION BY cohort_month ORDER BY months_since_signup ), 1) AS retention_pct FROM cohort_join GROUP BY cohort_month, months_since_signup ORDER BY cohort_month, months_since_signup; /* Execution order (logical SQL evaluation order): 1. CTE user_cohort 2. CTE user_activity 3. CTE cohort_join 4. Outer query 5. 100.0 * active / cohort_size → retention rate (%) */
LEGEND
① FROM
FROM user_eventsTen event logs from six users across three months (January–March). Split these into first-use-month cohorts and monthly activity, then join them to build the two-dimensional cohort table.| user_id | event_time |
|---|---|
| 1 | 2024-01-15 |
| 1 | 2024-02-10 |
| 1 | 2024-03-05 |
| 2 | 2024-01-20 |
| 2 | 2024-02-15 |
| 3 | 2024-01-25 |
| 4 | 2024-02-05 |
| 4 | 2024-03-12 |
| 5 | 2024-02-20 |
| 6 | 2024-03-15 |
FIRST_VALUE(active_users) OVER (PARTITION BY cohort_month ORDER BY months_since_signup) copies month 0's value (= initial cohort size) to every row in that cohort. Carrying the denominator on every row is a standard pattern for retention, share, and contribution metrics.(year difference × 12) + month difference is a standard way to get elapsed time in whole months. AGE() also exists, but it returns an interval that can be awkward for integer comparisons and ORDER BY, so direct EXTRACT arithmetic is often preferred in practice.(SELECT active_users FROM ... WHERE months_since_signup = 0) calculates one cohort at a time and is slower. A window function processes every cohort in one pass, making the difference visible at production scale.Behavioral-Path Aggregation — Extract common user paths with STRING_AGG
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)
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.
| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 2024-01-15 10:00:00 |
| 1 | cart | 2024-01-15 10:05:00 |
| 1 | purchase | 2024-01-15 10:10:00 |
| 2 | view | 2024-01-15 11:00:00 |
| 2 | cart | 2024-01-15 11:03:00 |
| 2 | purchase | 2024-01-15 11:08:00 |
| 3 | view | 2024-01-15 12:00:00 |
| 3 | cart | 2024-01-15 12:02:00 |
| 4 | view | 2024-01-15 13:00:00 |
| 4 | purchase | 2024-01-15 13:01:00 |
| 5 | view | 2024-01-15 14:00:00 |
| 5 | purchase | 2024-01-15 14:05:00 |
| path | user_count | path_length |
|---|---|---|
| view → cart → purchase | 2 | 3 |
| view → purchase | 2 | 2 |
| view → cart | 1 | 2 |
WITH user_paths AS ( -- Stage 1: group by user and concatenate events chronologically SELECT user_id, STRING_AGG(event_type, ' → ' ORDER BY event_time) AS path, -- STRING_AGG(column, delimiter ORDER BY order): concatenate strings while guaranteeing order COUNT(*) AS path_length -- COUNT(*): count the total events generated by the user FROM events GROUP BY user_id ) -- Stage 2: aggregate user counts by identical path (behavioral pattern) SELECT path, COUNT(*) AS user_count, -- COUNT(*) counts rows grouped by path (= users sharing the path) MAX(path_length) AS path_length -- MAX(): maximum in the group; for an identical path, MAX() and MIN() give the same length FROM user_paths GROUP BY path ORDER BY user_count DESC, path_length DESC; -- ORDER BY column DESC: sort from larger values to smaller values /* Execution order (logical SQL evaluation order): 1. CTE user_paths 2. Outer query */
LEGEND
STEP 1
events tableTwelve event logs from five users. Group by user_id and concatenate event names in event_time order.| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 10:00:00 |
| 1 | cart | 10:05:00 |
| 1 | purchase | 10:10:00 |
| 2 | view | 11:00:00 |
| 2 | cart | 11:03:00 |
| 2 | purchase | 11:08:00 |
| 3 | view | 12:00:00 |
| 3 | cart | 12:02:00 |
| 4 | view | 13:00:00 |
| 4 | purchase | 13:01:00 |
| 5 | view | 14:00:00 |
| 5 | purchase | 14:05:00 |
STRING_AGG(event_type, ' → ' ORDER BY event_time) explicitly sets the aggregation order. Omitting it can produce a broken path such as “view → purchase → cart,” making the analysis meaningless. PostgreSQL's ARRAY_AGG(event_type ORDER BY event_time) creates a structured array for further processing.user_id; the second aggregates patterns by path. This hierarchy applies to A/B-test pattern classification, purchase-path analysis, and screen-transition analysis.ARRAY_AGG(event_type ORDER BY event_time), followed by operations such as array_length, arr[1] (first element), and arr && ARRAY['cart'] (contains an element). Remember: strings are for display; arrays are for analysis.STRING_AGG(event_type, ' → ') may return a different order on each execution. It may appear to work by insertion or physical order, but production queries must include ORDER BY inside the argument.GROUP_CONCAT(event_type ORDER BY event_time SEPARATOR ' → '), while Oracle uses LISTAGG(event_type, ' → ') WITHIN GROUP (ORDER BY event_time). Always check the dialect matrix when porting SQL.Fill Missing Dates with a Recursive CTE — Visualize days without events as 0
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 + 1 -- recursive term (+1 day from the previous row) FROM date_series WHERE day < DATE '2024-01-15' -- termination condition )
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.
| user_id | event_time |
|---|---|
| 1 | 2024-01-10 09:00 |
| 2 | 2024-01-10 14:00 |
| 1 | 2024-01-12 10:00 |
| 3 | 2024-01-12 16:00 |
| 2 | 2024-01-14 11:00 |
| 1 | 2024-01-15 09:30 |
| day | active_users |
|---|---|
| 2024-01-10 | 2 |
| 2024-01-11 | 0 |
| 2024-01-12 | 2 |
| 2024-01-13 | 0 |
| 2024-01-14 | 1 |
| 2024-01-15 | 1 |
-- WITH RECURSIVE: declare a recursive CTE (self-reference becomes possible) WITH RECURSIVE date_series AS ( -- Stage 1: base case — return the start date as one row SELECT DATE '2024-01-10' AS day -- UNION ALL: combine SELECT results vertically while preserving duplicates UNION ALL -- Stage 2: recursive term — add one day to the most recent result SELECT day + 1 FROM date_series WHERE day < DATE '2024-01-15' -- continue only while day is before 2024-01-15 ), daily_dau AS ( -- truncate event times to days and count distinct users per day SELECT DATE_TRUNC('day', event_time)::DATE AS day, COUNT(DISTINCT user_id) AS active_users -- DISTINCT user_id: count each user only once FROM events GROUP BY DATE_TRUNC('day', event_time)::DATE ) -- LEFT JOIN from the date series and turn unmatched-day NULLs into 0 SELECT ds.day, COALESCE(dd.active_users, 0) AS active_users -- COALESCE(value1, value2): return value2 when value1 is NULL; standard missing-value fill FROM date_series ds -- LEFT JOIN: keep every left-table row; join only matching right-table rows LEFT JOIN daily_dau dd ON ds.day = dd.day ORDER BY ds.day; /* Execution order (logical SQL evaluation order): 1. CTE date_series (WITH RECURSIVE) 2. CTE daily_dau 3. Outer query */
LEGEND
STEP 1
events table (sparse)Six event logs. There are no events on 01-11 or 01-13, so this table alone cannot represent days with zero DAU.| user_id | event_time |
|---|---|
| 1 | 2024-01-10 09:00 |
| 2 | 2024-01-10 14:00 |
| 1 | 2024-01-12 10:00 |
| 3 | 2024-01-12 16:00 |
| 2 | 2024-01-14 11:00 |
| 1 | 2024-01-15 09:30 |
UNION ALL (the base case) is evaluated once; the right side (the recursive term) repeatedly uses the row just added. Each iteration ends when WHERE becomes false. This is ideal for date series, hierarchies, and sequences such as Fibonacci numbers up to N.generate_series('2024-01-10'::date, '2024-01-15'::date, INTERVAL '1 day') is a dedicated alternative, but WITH RECURSIVE is especially valuable for understanding the concept.WHERE day < '2024-01-15', the recursive term generates rows forever and can exhaust memory or crash the database. Always include a condition that becomes false after a finite number of iterations. Because this query feeds the latest added row back into the recursion, its date increment naturally reaches the end.JOIN (= INNER JOIN) drops 01-11 and 01-13 because they are absent from daily_dau. Put the side whose full period must be preserved on the left of LEFT JOIN.WHERE dd.active_users IS NOT NULL removes unmatched rows, effectively undoing the LEFT JOIN. Put right-side conditions in ON, or filter the source before the FROM clause.day, year, month, day_of_week, is_weekend, fiscal_quarter, ... can serve as the JOIN axis for nearly every time-series query. dbt packages such as dbt_date and dbt_utils.date_spine implement this idea internally. Understanding recursive CTEs helps explain why a date dimension is useful.