Funnel analysis is one of the most important analyses in event modeling. It calculates reach counts and conversion rates for steps such as “view → click → purchase.” Converting a long event table into wide flags is the starting point.
-- Create boolean flags with CASE WHEN (1=reached, 0=not reached) MAX(CASE WHEN event_type = 'view' THEN 1 ELSE 0 END) AS did_view -- Return NULL for a zero denominator with NULLIF (avoid division-by-zero errors) ROUND(num * 100.0 / NULLIF(denom, 0), 1)
MAX(CASE WHEN...THEN 1 ELSE 0 END) returns 1 if the event occurred at least once and 0 otherwise.Because it is a general-purpose pattern that also works outside PostgreSQL (BigQuery, MySQL, and Redshift), it is often used in practice instead of FILTER. A subsequent SUM() calculates the number of users who reached the step.From the user_events table, calculate the number of users reaching each funnel step (view → click → purchase) and the conversion rate between steps. Return one row with the output columns total_users, view_users, click_users, purchase_users, view_to_click_pct, click_to_purch_pct.
| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 2024-01-10 10:00 |
| 1 | click | 2024-01-10 10:05 |
| 1 | purchase | 2024-01-10 10:30 |
| 2 | view | 2024-01-10 11:00 |
| 2 | click | 2024-01-10 11:10 |
| 3 | view | 2024-01-11 09:00 |
| 4 | view | 2024-01-11 14:00 |
| 4 | click | 2024-01-11 14:20 |
| 4 | purchase | 2024-01-11 14:50 |
Note: user1 and user4 reached every step; user2 reached view and click; user3 reached view only.
Expected output (one row):
| total_users | view_users | click_users | purchase_users | view_to_click_pct | click_to_purch_pct |
|---|---|---|---|---|---|
| 4 | 4 | 3 | 2 | 75.0 | 66.7 |
view_to_click_pct = ROUND(3×100.0/4, 1) = 75.0. click_to_purch_pct = ROUND(2×100.0/3, 1) = 66.7.
WITH funnel AS ( SELECT user_id, MAX(CASE WHEN event_type = 'view' THEN 1 ELSE 0 END) AS did_view, -- 1 if view occurred at least once MAX(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS did_click, MAX(CASE WHEN event_type = 'purchase' THEN 1 ELSE 0 END) AS did_purchase FROM user_events GROUP BY user_id ) SELECT COUNT(*) AS total_users, SUM(did_view) AS view_users, SUM(did_click) AS click_users, SUM(did_purchase) AS purchase_users, ROUND(SUM(did_click) * 100.0 / NULLIF(SUM(did_view), 0), 1) AS view_to_click_pct, -- view→click conversion rate (%) ROUND(SUM(did_purchase) * 100.0 / NULLIF(SUM(did_click), 0), 1) AS click_to_purch_pct -- click→purchase conversion rate (%) FROM funnel; /* Logical execution order: 1. CTE funnel 2. Outer query */
LEGEND
① FROM user_events (9 rows)
FROM user_eventsRead the 9 rows from the user_events table. Four users participate in the view / click / purchase steps. The goal is to aggregate which steps each user reached.| user_id | event_type |
|---|---|
| 1 | view |
| 1 | click |
| 1 | purchase |
| 2 | view |
| 2 | click |
| 3 | view |
| 4 | view |
| 4 | click |
| 4 | purchase |
MAX(...THEN 1 ELSE 0...) returns 1. Combined with SUM, it accurately calculates the number of users who reached the step at least once.This pattern is faster than COUNT(DISTINCT user_id) FILTER because it completes in one pass before GROUP BY.NULLIF(x, 0) returns NULL when x = 0. NULL divided by anything is NULL (not an error). When view_users is 0, the downstream conversion rate is undefined, so returning NULL is correct. Returning 0 risks being misread as a 0% conversion rate.GROUP BY user_id.CASE WHEN event_type = 'view' THEN 1 END returns NULL rather than 0 when the condition is false. Although SUM ignores NULL values, COUNT can be affected, so ELSE 0 must be written explicitly.CASE WHEN signup_month = '2024-01' THEN 'Jan cohort' or another cohort dimension to the query enables funnel comparisons by cohort and directly supports measuring the impact of initiatives.QUESTION 6’s reach-based funnel did not consider order, but in practice a strict funnel may be required to evaluate only users who progressed in the order view → click → purchase.
-- Get the first timestamp for each step MIN(CASE WHEN event_type = 'view' THEN event_time END) AS view_time -- Check whether the next step occurs at or after the previous step CASE WHEN view_time <= click_time THEN 1 ELSE 0 END
view_time <= click_time becomes UNKNOWN if either value is NULL, so users who did not reach a step are automatically excluded.From the user_events table, calculate the number of users who passed through each step (view → click → purchase) in order. Return one row with the output columns total_users, view_users, click_users, purchase_users.
| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 2024-01-10 10:00 |
| 1 | click | 2024-01-10 10:05 |
| 1 | purchase | 2024-01-10 10:30 |
| 2 | click | 2024-01-10 11:00 |
| 2 | view | 2024-01-10 11:10 |
| 3 | view | 2024-01-11 09:00 |
| 4 | view | 2024-01-11 14:00 |
| 4 | purchase | 2024-01-11 14:10 |
| 4 | click | 2024-01-11 14:20 |
| 5 | view | 2024-01-12 10:00 |
Note: user2 clicked before viewing; user4 purchased before clicking.
Expected output (one row):
| total_users | view_users | click_users | purchase_users |
|---|---|---|---|
| 5 | 5 | 2 | 1 |
user1 followed the full order through purchase. user4 followed the order through click, but purchase was out of order. user2 has only view because click occurred out of order.
WITH user_times AS ( SELECT user_id, MIN(CASE WHEN event_type = 'view' THEN event_time END) AS view_time, -- time of the first view MIN(CASE WHEN event_type = 'click' THEN event_time END) AS click_time, MIN(CASE WHEN event_type = 'purchase' THEN event_time END) AS purchase_time FROM user_events GROUP BY user_id ) SELECT COUNT(*) AS total_users, SUM(CASE WHEN view_time IS NOT NULL THEN 1 ELSE 0 END) AS view_users, SUM(CASE WHEN view_time <= click_time THEN 1 ELSE 0 END) AS click_users, -- satisfies the view→click order SUM(CASE WHEN view_time <= click_time AND click_time <= purchase_time THEN 1 ELSE 0 END) AS purchase_users -- view→click→purchase order FROM user_times; /* Logical execution order: 1. CTE user_times 2. Outer query */
LEGEND
① FROM user_events(10 rows)
FROM user_eventsThese are the event histories of 5 users. user2 and user4 have histories with events in the wrong order.| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 10:00 |
| 1 | click | 10:05 |
| 1 | purchase | 10:30 |
| 2 | click | 11:00 |
| 2 | view | 11:10 |
| 3 | view | 09:00 |
| 4 | view | 14:00 |
| 4 | purchase | 14:10 |
| 4 | click | 14:20 |
| 5 | view | 10:00 |
event_time preserves when the user reached the event, not just whether they reached it, making order validation possible.view_time <= click_time becomes UNKNOWN and is treated as FALSE (returns 0) when either value is NULL. Thus users who did not reach a step or have a missing event are automatically excluded.LAG() to check directly whether the immediately preceding event was view; another uses MATCH_RECOGNIZE (supported by Snowflake and others). Choosing between them based on whether first reach or only consecutive transitions should count is where a data engineer’s judgment matters.Cohort analysis tracks the behavior of user groups (cohorts) who signed up around the same time. Aggregating whether users were active within 30 days of signup by signup month makes it possible to determine whether an improvement initiative affected a particular cohort.
-- Add an INTERVAL condition to the ON clause to limit the period LEFT JOIN user_events e ON c.user_id = e.user_id AND e.event_time >= c.signup_date AND e.event_time < c.signup_date + INTERVAL '30 days'
From the users and user_events tables, calculate the cohort size, active users with an event within 30 days of signup, and retention rate for each signup-month cohort. Return the output columns cohort_month, cohort_size, active_users, retention_pct, ordered by cohort_month ascending.
| user_id | signup_date |
|---|---|
| 1 | 2024-01-05 |
| 2 | 2024-01-15 |
| 3 | 2024-02-01 |
| 4 | 2024-02-10 |
| 5 | 2024-02-20 |
| user_id | event_time |
|---|---|
| 1 | 2024-01-07 |
| 1 | 2024-01-20 |
| 2 | 2024-01-16 |
| 3 | 2024-02-03 |
| 4 | 2024-03-15 |
Note: user4’s event (3/15) is more than 30 days after signup_date (2/10); user5 has no events.
Expected output (cohort_month ascending):
| cohort_month | cohort_size | active_users | retention_pct |
|---|---|---|---|
| 2024-01-01 | 2 | 2 | 100.0 |
| 2024-02-01 | 3 | 1 | 33.3 |
Both users in the January cohort (user1 and user2) are active. Only user3 is active in the February cohort (user3–user5).
WITH cohorts AS ( -- Define the cohort month SELECT user_id, signup_date, DATE_TRUNC('month', signup_date)::date AS cohort_month FROM users ), activity AS ( -- Activity within 30 days SELECT c.user_id, c.cohort_month, COUNT(e.event_time) AS event_count -- 0 = inactive FROM cohorts c LEFT JOIN user_events e ON c.user_id = e.user_id AND e.event_time >= c.signup_date AND e.event_time < c.signup_date + INTERVAL '30 days' -- Time-window condition in ON clause GROUP BY c.user_id, c.cohort_month ) SELECT cohort_month, COUNT(*) AS cohort_size, COUNT(*) FILTER (WHERE event_count > 0) AS active_users, ROUND( COUNT(*) FILTER (WHERE event_count > 0) * 100.0 / NULLIF(COUNT(*), 0), 1) AS retention_pct FROM activity GROUP BY cohort_month ORDER BY cohort_month; /* Logical execution order: 1. CTE cohorts 2. CTE activity 3. Outer query */
LEGEND
① FROM users(5 rows)— Define the cohort month
DATE_TRUNC('month', signup_date) → cohort_monthRead the users table and use DATE_TRUNC to assign signup month (cohort_month). January signups are user1 and user2; February signups are user3, user4, and user5, forming two cohorts.| user_id | signup_date | cohort_month |
|---|---|---|
| 1 | 2024-01-05 | 2024-01-01 |
| 2 | 2024-01-15 | 2024-01-01 |
| 3 | 2024-02-01 | 2024-02-01 |
| 4 | 2024-02-10 | 2024-02-01 |
| 5 | 2024-02-20 | 2024-02-01 |
e.event_time < c.signup_date + INTERVAL '30 days' in WHERE filters out NULL rows after the join, making it equivalent to INNER JOIN. When users with no events must remain in the denominator, always use the ON clause. This is especially important for cohort retention because every cohort member must be in the denominator.COUNT(NULL) returns 0 (unlike COUNT(*), it ignores NULL). Thus event_count = 0 means no activity, and FILTER (WHERE event_count > 0) counts active users accurately.DATE_TRUNC('week', signup_date) or DATE_TRUNC('quarter', signup_date) changes the cohort granularity. Choose the granularity to match the period in which you want to measure an initiative’s impact.COUNT(*) inside the activity CTE counts the NULL row from LEFT JOIN as 1. event_count becomes 1 instead of 0, falsely marking every user as active. To safely count a nullable column, use COUNT(non_null_column).In event modeling, engagement segmentation by behavior frequency is a foundational analysis. The standard two-step pattern is to aggregate each user’s event count in a CTE, then classify segments with an outer CASE WHEN.
CASE WHEN event_count >= 5 THEN 'power' WHEN event_count >= 2 THEN 'casual' ELSE 'light' END -- WHEN clauses are evaluated from top to bottom; the first true THEN is used
event_count >= 2 works correctly without explicitly stating “at least 2 and less than 5.” Ordering conditions from a broader range to a narrower range lets you omit overlapping conditions.From the user_events table, aggregate each user’s event count and classify users into three segments: power (5 or more), casual (2–4), and light (1). Return the output columns segment, user_count, avg_events (avg_events rounded to one decimal place), ordered by user_count descending and segment name ascending.
| user_id | event_type |
|---|---|
| 1 | view |
| 1 | click |
| 1 | purchase |
| 1 | view |
| 1 | click |
| 2 | view |
| 2 | click |
| 2 | purchase |
| 3 | view |
| 3 | click |
| 4 | view |
Note: user1=5 events → power, user2=3 → casual, user3=2 → casual, user4=1 → light.
Expected output (user_count descending / segment ascending):
| segment | user_count | avg_events |
|---|---|---|
| casual | 2 | 2.5 |
| light | 1 | 1.0 |
| power | 1 | 5.0 |
casual is largest (average 2.5 from user2=3 and user3=2). light and power each have one user, so segment name breaks the tie (l < p).
WITH event_counts AS ( -- Per-user event counts SELECT user_id, COUNT(*) AS event_count FROM user_events GROUP BY user_id ), segmented AS ( -- Add a segment column with CASE WHEN SELECT user_id, event_count, CASE WHEN event_count >= 5 THEN 'power' -- evaluated first (5 or more) WHEN event_count >= 2 THEN 'casual' -- evaluated next (at least 2 and less than 5) ELSE 'light' -- otherwise (1) END AS segment FROM event_counts ) SELECT segment, COUNT(*) AS user_count, ROUND(AVG(event_count), 1) AS avg_events -- Average event count within the segment FROM segmented GROUP BY segment ORDER BY user_count DESC, segment; -- user_count descending; segment ascending for ties /* Logical execution order: 1. CTE event_counts 2. CTE segmented 3. Outer query */
LEGEND
① FROM user_events(11 rows)
FROM user_eventsRead the 11 rows from user_events. user1 has the most with 5 and is a power candidate; user2 has 3 and user3 has 2 (casual candidates); user4 has 1 (a light candidate).| user_id | event_type |
|---|---|
| 1 | view |
| 1 | click |
| 1 | purchase |
| 1 | view |
| 1 | click |
| 2 | view |
| 2 | click |
| 2 | purchase |
| 3 | view |
| 3 | click |
| 4 | view |
WHEN event_count >= 2 THEN 'casual' works correctly without explicitly stating “at least 2 and less than 5.” Ordering thresholds from broader to narrower ranges is easier to read and less error-prone.ROUND(AVG(event_count), 1) rounds to one decimal place. In PostgreSQL, AVG returns NUMERIC (variable precision), so ROUND can be applied. AVG of an integer column automatically becomes NUMERIC with a fractional part, so the average remains accurate even when it does not divide evenly.NTILE(3) OVER (ORDER BY event_count DESC) can create segments that divide the data distribution equally into top, middle, and bottom 33%. This is useful when thresholds cannot be set in advance or user counts should be balanced.WHEN event_count >= 2 THEN 'casual' first classifies a user with event_count = 5 as 'casual'. Ignoring CASE WHEN evaluation order creates unintended segments. Put the most restrictive condition (>=5) first.Dormant-user detection is a key event-modeling task. To find users whose last event was at least N days ago or who have never had an event, the basic pattern is LEFT JOIN + GROUP BY + HAVING.
FROM users u LEFT JOIN user_events e ON u.user_id = e.user_id GROUP BY u.user_id, u.signup_date HAVING MAX(e.event_time) < '2024-01-25' -- at least the required number of days before the reference date OR MAX(e.event_time) IS NULL -- no event ever
From the users and user_events tables, users whose last event was before 2024-01-25 (at least 21 days before the reference date 2024-02-15), or who have never had an event. Return user_id, signup_date, last_event_time, ordered by last_event_time ascending with NULLS FIRST, then user_id ascending.
| user_id | signup_date |
|---|---|
| 1 | 2024-01-01 |
| 2 | 2024-01-05 |
| 3 | 2024-01-10 |
| 4 | 2024-01-20 |
| 5 | 2024-02-01 |
| user_id | event_time |
|---|---|
| 1 | 2024-01-10 10:00 |
| 1 | 2024-01-20 14:00 |
| 2 | 2024-01-10 09:00 |
| 3 | 2024-02-01 11:00 |
| 3 | 2024-02-10 15:00 |
| 4 | 2024-01-22 08:00 |
Note: user1 last=1/20 (26 days ago → dormant), user2 last=1/10 (36 days ago → dormant), user3 last=2/10 (active), user4 last=1/22 (24 days ago → dormant), user5=no events
Expected output (last_event_time NULLS FIRST / user_id ascending):
| user_id | signup_date | last_event_time |
|---|---|---|
| 5 | 2024-02-01 | NULL |
| 1 | 2024-01-01 | 2024-01-20 14:00 |
| 2 | 2024-01-05 | 2024-01-10 09:00 |
| 4 | 2024-01-20 | 2024-01-22 08:00 |
user3’s last event (2/10) was 5 days before the reference date (2/15), so it is active and excluded.
SELECT u.user_id, u.signup_date, MAX(e.event_time) AS last_event_time -- last event timestamp (NULL when there are no events) FROM users u LEFT JOIN user_events e ON u.user_id = e.user_id -- retain users with no events GROUP BY u.user_id, u.signup_date HAVING MAX(e.event_time) < '2024-01-25'::date -- last event was at least 21 days ago OR MAX(e.event_time) IS NULL -- no event ever ORDER BY last_event_time ASC NULLS FIRST, -- put NULL (no event) first u.user_id; /* Logical execution order: 1. FROM users LEFT JOIN user_events → retain all users 2. GROUP BY u.user_id, u.signup_date → aggregate and get MAX(event_time) 3. HAVING ... → select last events before the reference date or NULL 4. ORDER BY last_event_time ASC NULLS FIRST → sort and output */
LEGEND
① FROM users(5 rows)
usersThis is the starting users table. Dormancy is evaluated for all 5 users.| user_id | signup_date |
|---|---|
| 1 | 2024-01-01 |
| 2 | 2024-01-05 |
| 3 | 2024-01-10 |
| 4 | 2024-01-20 |
| 5 | 2024-02-01 |
NULLS FIRST directly supports prioritizing email campaigns for dormant users.MAX(e.event_time) < '2024-01-25' alone does not let NULL rows (users with no events) pass HAVING. Including OR MAX(e.event_time) IS NULL captures users with no events correctly.WHERE MAX(e.event_time) < '2024-01-25' causes a SQL syntax error. Aggregate functions have not been calculated at the WHERE stage and cannot be referenced. Always use HAVING to filter aggregate results.HAVING MAX(e.event_time) < '2024-01-25' treats NULL (users with no events) as UNKNOWN, so they do not pass HAVING. This misses high-risk users with no history, so OR IS NULL is required.