Event Modeling — Learn Funnels, cohorts, cumulative aggregation, and segmentation from the Basics
BasicEvent modelingFunnel analysisCohort analysisCumulative aggregationUser segmentationPostgreSQL5 questions
QUESTION 6
Funnel Analysis — Set step-reach flags with CASE WHEN + MAX and prevent division by zero with NULLIF
CASE WHENNULLIFFunnel analysisConversion rate
Background

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)
Choosing between MAX(CASE WHEN) and COUNT(*) FILTER: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.
Problem

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.

Tables used
► user_events (9 rows)
user_idevent_typeevent_time
1view2024-01-10 10:00
1click2024-01-10 10:05
1purchase2024-01-10 10:30
2view2024-01-10 11:00
2click2024-01-10 11:10
3view2024-01-11 09:00
4view2024-01-11 14:00
4click2024-01-11 14:20
4purchase2024-01-11 14:50

Note: user1 and user4 reached every step; user2 reached view and click; user3 reached view only.

Expected Output

Expected output (one row):

total_usersview_usersclick_userspurchase_usersview_to_click_pctclick_to_purch_pct
443275.066.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.

Model Answer
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
  */
Explanation (table transitions & key points)
WITH funnel AS ( SELECT user_id, MAX(CASE WHEN event_type = 'view' THEN 1 ELSE 0 END) AS did_view, 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, ROUND(SUM(did_purchase)*100.0/NULLIF(SUM(did_click),0),1) AS click_to_purch_pct FROM funnel;
LEGEND
Rows read / loaded
① 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.
1 / 5
user_idevent_type
1view
1click
1purchase
2view
2click
3view
4view
4click
4purchase
9 rows loaded (4 users × multiple events)
LEARNING POINTS
MAX(CASE WHEN) is an efficient expression for “has ever reached”:Even if the same user fires view three times, 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.
Prevent division-by-zero errors with NULLIF(expr, 0):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.
Strict-order vs. reach-based funnels:This query is reach-based: it independently checks whether each event occurred at least once. In practice, a strict funnel may count only users who progressed in the order view → click → purchase. In that case, use ROW_NUMBER() in a CTE to validate the order, or extend the query to verify event_time ordering with JOINs.
ANTI-PATTERNS
Using MAX(CASE WHEN) without GROUP BY: Omitting GROUP BY user_id makes all 9 rows one group; if the table contains even one click, did_click = 1 is returned. For per-user reach flags, always use GROUP BY user_id.
Omitting the ELSE clause: Writing 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.
Practical column: From funnel analysis to identifying drop-off points
Once funnel metrics are available, the next step is to investigate where users drop off. For example, when view_to_click_pct is low, improving the landing page or UI can help, while a low click_to_purch_pct may indicate an issue with the checkout flow or pricing. Adding 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 7
Funnel Analysis (Strict Order) — Get first event times with MIN() and CASE WHEN to validate the sequence
MINCASE WHENFunnel analysisOrder validation
Background

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
Strict evaluation by timestamp ordering: Extract the first occurrence time for each step and compare it with the preceding step. view_time <= click_time becomes UNKNOWN if either value is NULL, so users who did not reach a step are automatically excluded.
Problem

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.

Tables used
► user_events(10 rows)
user_idevent_typeevent_time
1view2024-01-10 10:00
1click2024-01-10 10:05
1purchase2024-01-10 10:30
2click2024-01-10 11:00
2view2024-01-10 11:10
3view2024-01-11 09:00
4view2024-01-11 14:00
4purchase2024-01-11 14:10
4click2024-01-11 14:20
5view2024-01-12 10:00

Note: user2 clicked before viewing; user4 purchased before clicking.

Expected Output

Expected output (one row):

total_usersview_usersclick_userspurchase_users
5521

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.

Model Answer
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
  */
Explanation (table transitions & key points)
WITH user_times AS ( SELECT user_id, MIN(CASE WHEN event_type = 'view' THEN event_time END) AS view_time, 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, SUM(CASE WHEN view_time <= click_time AND click_time <= purchase_time THEN 1 ELSE 0 END) AS purchase_users FROM user_times;
LEGEND
Rows read / loaded
① 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.
1 / 5
user_idevent_typeevent_time
1view10:00
1click10:05
1purchase10:30
2click11:00
2view11:10
3view09:00
4view14:00
4purchase14:10
4click14:20
5view10:00
10 rows loaded
LEARNING POINTS
Extracting the first timestamp of a specific event with MIN(CASE WHEN):Instead of Q6’s 1/0 flag, extracting event_time preserves when the user reached the event, not just whether they reached it, making order validation possible.
Strict funnels through chronological comparison:Comparing “previous step time <= next step time” excludes noisy records with reversed event order, such as specification bugs or invalid transitions.
Take care when comparing with NULL: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.
ANTI-PATTERNS
Ignoring repeated events (using MAX): Using MAX() instead of MIN() retrieves the last click time. A user who initially followed the order could be falsely marked as out of order because of a later re-click. Use MIN() when evaluating the first passage.
Practical column: Extending to session-based order validation
In practice, requirements may include consecutive transitions within the same session, not just first-reach order. One powerful approach uses the window function 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.
QUESTION 8
Cohort Analysis Basics — Calculate the first-30-day retention rate for each signup-month cohort
LEFT JOININTERVALCohort analysisRetention
Background

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'
Why put INTERVAL in the ON clause: Putting the condition in WHERE makes LEFT JOIN equivalent to INNER JOIN, removing users with no events. Always put time-window conditions in the ON clause so users with no events remain with NULL in the event columns. This is the core of the LEFT JOIN + INTERVAL pattern.
Problem

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.

Tables used
► users(5 rows)
user_idsignup_date
12024-01-05
22024-01-15
32024-02-01
42024-02-10
52024-02-20
► user_events(5 rows)
user_idevent_time
12024-01-07
12024-01-20
22024-01-16
32024-02-03
42024-03-15

Note: user4’s event (3/15) is more than 30 days after signup_date (2/10); user5 has no events.

Expected Output

Expected output (cohort_month ascending):

cohort_monthcohort_sizeactive_usersretention_pct
2024-01-0122100.0
2024-02-013133.3

Both users in the January cohort (user1 and user2) are active. Only user3 is active in the February cohort (user3–user5).

Model Answer
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
  */
Explanation (table transitions & key points)
WITH cohorts AS ( SELECT user_id, signup_date, DATE_TRUNC('month', signup_date)::date AS cohort_month FROM users ), activity AS ( SELECT c.user_id, c.cohort_month, COUNT(e.event_time) AS event_count 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' 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;
LEGEND
Rows read / loaded
① 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.
1 / 6
user_idsignup_datecohort_month
12024-01-052024-01-01
22024-01-152024-01-01
32024-02-012024-02-01
42024-02-102024-02-01
52024-02-202024-02-01
5 rows × 3 columns (cohort_month added)
LEARNING POINTS
INTERVAL in ON vs. WHERE: Putting 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.
How COUNT(e.event_time) skips NULL: After the LEFT JOIN, event_time is NULL for users with no events. 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.
Changing cohorts to weekly or quarterly periods: Changing to 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.
ANTI-PATTERNS
Moving the INTERVAL condition to WHERE removes inactive users: Moving the time condition to WHERE removes users with no events (user4 and user5) from the result. Cohort sizes are no longer correctly 2→2 and 3→1, causing a fatal error in the retention calculation.
Using COUNT(*) instead of COUNT(e.event_time): Using 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).
Practical column: Reading a cohort retention matrix
Repeating cohort analysis for multiple periods (7, 30, and 90 days after signup) creates a signup-month × observation-period retention matrix. Each row is a cohort and each column is elapsed time, making it easy to see how retention changes from the upper left to the lower right. If a particular cohort alone has high 30-day retention, initiatives during that period (such as improved onboarding or email campaigns) likely worked, directly supporting validation of their effectiveness.
QUESTION 9
User Segmentation — Classify engagement levels with CASE WHEN and calculate averages by segment
CASE WHENAVGUser segmentationEngagement
Background

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
CASE WHEN evaluation order (short-circuit):CASE WHEN conditions are evaluated from top to bottom and stop at the first true condition. Therefore, 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.
Problem

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.

Tables used
► user_events(11 rows)
user_idevent_type
1view
1click
1purchase
1view
1click
2view
2click
2purchase
3view
3click
4view

Note: user1=5 events → power, user2=3 → casual, user3=2 → casual, user4=1 → light.

Expected Output

Expected output (user_count descending / segment ascending):

segmentuser_countavg_events
casual22.5
light11.0
power15.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).

Model Answer
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
  */
Explanation (table transitions & key points)
WITH event_counts AS ( SELECT user_id, COUNT(*) AS event_count FROM user_events GROUP BY user_id ), segmented AS ( SELECT user_id, event_count, CASE WHEN event_count >= 5 THEN 'power' WHEN event_count >= 2 THEN 'casual' ELSE 'light' END AS segment FROM event_counts ) SELECT segment, COUNT(*) AS user_count, ROUND(AVG(event_count), 1) AS avg_events FROM segmented GROUP BY segment ORDER BY user_count DESC, segment;
LEGEND
Rows read / loaded
① 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).
1 / 4
user_idevent_type
1view
1click
1purchase
1view
1click
2view
2click
2purchase
3view
3click
4view
11 rows loaded (4 users)
LEARNING POINTS
CASE WHEN short-circuit evaluation and boundary design:WHEN clauses are evaluated from top to bottom and stop at the first true condition. Therefore, 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.
Combining AVG and ROUND: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.
Equal-sized segments with NTILE():Here the segments use business-logic thresholds, but 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.
ANTI-PATTERNS
Reversing CASE WHEN order: Putting 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.
Trying to create segments with CASE WHEN without GROUP BY aggregation:If the event_counts CTE is omitted and CASE WHEN is applied directly to user_events, each row receives a segment without per-user aggregation. Since one row is one event, every row becomes 'light' (1). Behavior-frequency segmentation must use values aggregated after GROUP BY.
Practical column: Extending to RFM analysis
Here we segmented only by Frequency, but in practice RFM analysis combines three axes (Recency: days since last purchase, Frequency: purchase count, and Monetary: purchase amount). The pattern of calculating three CASE WHEN expressions in separate CTEs and summing their scores to determine a segment follows directly from this approach. RFM Recency requires the “last event date calculation” covered in Q10, so Q9 and Q10 are best understood together.
QUESTION 10
Dormant-User Detection — Identify users inactive for a set period by evaluating their last activity date with LEFT JOIN + HAVING
LEFT JOINHAVINGdormantuserschurn detection
Background

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
Choosing between WHERE and HAVING:WHERE filters rows before grouping. HAVING applies conditions after grouping and aggregation. Always use HAVING to filter the result of an aggregate such as MAX(). Writing WHERE MAX(e.event_time) < ... causes an error. Adding NULLS FIRST to ORDER BY also places users with no last event first.
Problem

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.

Tables used
► users(5 rows)
user_idsignup_date
12024-01-01
22024-01-05
32024-01-10
42024-01-20
52024-02-01
► user_events(6 rows)
user_idevent_time
12024-01-10 10:00
12024-01-20 14:00
22024-01-10 09:00
32024-02-01 11:00
32024-02-10 15:00
42024-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

Expected output (last_event_time NULLS FIRST / user_id ascending):

user_idsignup_datelast_event_time
52024-02-01NULL
12024-01-012024-01-20 14:00
22024-01-052024-01-10 09:00
42024-01-202024-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.

Model Answer
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
  */
Explanation (table transitions & key points)
SELECT u.user_id, u.signup_date, MAX(e.event_time) AS last_event_time 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'::date OR MAX(e.event_time) IS NULL ORDER BY last_event_time ASC NULLS FIRST, u.user_id;
LEGEND
Rows read / loaded
① FROM users(5 rows)
usersThis is the starting users table. Dormancy is evaluated for all 5 users.
1 / 6
user_idsignup_date
12024-01-01
22024-01-05
32024-01-10
42024-01-20
52024-02-01
5 rows
LEARNING POINTS
The essential difference between WHERE and HAVING:WHERE filters rows before grouping. HAVING filters groups after grouping and aggregation. Always use HAVING to filter aggregate values such as MAX(), COUNT(), and SUM(). Putting an aggregate in WHERE causes a SQL syntax error.
Controlling NULL placement with NULLS FIRST / NULLS LAST:PostgreSQL defaults to NULLS LAST (NULL at the end) for ORDER BY ASC and NULLS FIRST for DESC. Putting users with no events ever (the highest-risk users with no activity history) first with NULLS FIRST directly supports prioritizing email campaigns for dormant users.
Handling NULL correctly in HAVING:NULL returns UNKNOWN for every comparison operator (<, >, =), so 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.
ANTI-PATTERNS
Writing an aggregate function in WHERE: Writing 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.
Omitting OR IS NULL in HAVING: Using only 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.
Practical column: Applying dormant-user detection to re-engagement campaigns
Once the dormant-user list is available, the next step is a re-engagement (Win-back Campaign) initiative. Users with a recent signup_date should be approached differently from users who signed up more than a year ago. Classifying dormancy duration bands (short, medium, long) with CASE WHEN (combined with Q9) can then contribute to customer-success metrics. Combining the patterns from Q6–Q10 builds a practical event-analysis pipeline.