Behavioral Analytics — Learn Cohorts, Retention, Funnels, and Churn from the Basics
BasicBehavioral analyticsCohorts / retentionFunnels / churnWindow functionsPostgreSQL-ready5 questions
QUESTION 1
Cohort Analysis — Define registration-month cohorts with DATE_TRUNC('month') and calculate first-month activity rate
DATE_TRUNCLEFT JOINCohort analysisFirst-month activity
Background

Cohort analysis tracks the behavior of a group of users acquired during the same period (a cohort). First, DATE_TRUNC('month', registered_at) rounds each registration date down to the first day of its month, treating users registered in the same month as one cohort.

DATE_TRUNC('month', '2024-01-15'::date)  -- → 2024-01-01
DATE_TRUNC('month', '2024-02-22'::date)  -- → 2024-02-01
The date condition in LEFT JOIN is the core of cohort analysis: Adding AND DATE_TRUNC('month', e.event_date) = DATE_TRUNC('month', u.registered_at) to the LEFT JOIN ON clause joins only events where the login occurred in the registration month. For users with no match, e.user_id becomes NULL, so COUNT(DISTINCT e.user_id) accurately counts first-month active users.
Problem

Using the users and login_events tables, calculate the first-month activity rate for each registration-month cohort. Return cohort_month, cohort_size, active_in_first_month, first_month_active_pct (rounded to one decimal place), ordered by cohort_month ascending.

Tables used
▸ users
user_idregistered_at
12024-01-10
22024-01-15
32024-01-22
42024-02-05
52024-02-14
62024-02-20
▸ login_events
user_idevent_date
12024-01-12
22024-01-18
32024-02-03
42024-02-07
52024-03-05
62024-02-22

※ user3 logged in during February (registered in January → outside the first month); user5 logged in during March (registered in February → outside the first month)

Expected Output

Expected output (cohort_month ascending):

cohort_monthcohort_sizeactive_in_first_monthfirst_month_active_pct
2024-01-013266.7
2024-02-013266.7

January cohort: users 1 and 2 logged in during the first month; user3 logged in during February and is not counted. February cohort: users 4 and 6 logged in during the first month; user5 logged in during March and is not counted.

Model Answer
SELECT
  DATE_TRUNC('month', u.registered_at)::date  AS cohort_month,
  COUNT(DISTINCT u.user_id)                    AS cohort_size,
  COUNT(DISTINCT e.user_id)                    AS active_in_first_month,  -- NULL values are not counted
  ROUND(
    COUNT(DISTINCT e.user_id) * 100.0 /
    COUNT(DISTINCT u.user_id), 1
  ) AS first_month_active_pct
FROM   users u
LEFT JOIN login_events e
  ON  e.user_id = u.user_id
  AND DATE_TRUNC('month', e.event_date)         -- Check whether the event is in the registration month
    = DATE_TRUNC('month', u.registered_at)
GROUP BY cohort_month
ORDER BY cohort_month;

/*
  Logical execution order:
  1. FROM users u            → Read rows
  2. LEFT JOIN login_events  → Join while retaining every left-side row
  3. GROUP BY registered_at  → Form groups
  4. Evaluate aggregates     → COUNT(DISTINCT ...)
  5. SELECT                  → Format values
  6. ORDER BY cohort_month   → Sort and output
*/
Explanation (table transitions & key points)
SELECT DATE_TRUNC('month', u.registered_at)::date AS cohort_month, COUNT(DISTINCT u.user_id) AS cohort_size, COUNT(DISTINCT e.user_id) AS active_in_first_month, ROUND( COUNT(DISTINCT e.user_id) * 100.0 / COUNT(DISTINCT u.user_id), 1 ) AS first_month_active_pct FROM users u LEFT JOIN login_events e ON e.user_id = u.user_id AND DATE_TRUNC('month', e.event_date) = DATE_TRUNC('month', u.registered_at) GROUP BY cohort_month ORDER BY cohort_month;
LEGEND
Rows read / loaded
① FROM users + login_events (2 tables)
FROM users u / login_events eRead the users (6 rows) and login_events (6 rows) tables. Because this is a LEFT JOIN, users is the base table and unmatched login_events rows are excluded.
1 / 4
▸ users u
user_idregistered_at
12024-01-10
22024-01-15
32024-01-22
42024-02-05
52024-02-14
62024-02-20
▸ login_events e
user_idevent_date
12024-01-12
22024-01-18
32024-02-03
42024-02-07
52024-03-05
62024-02-22
users: 6 rows / login_events: 6 rows
LEARNING POINTS
Why put the date condition in the JOIN ON clause: Putting the date condition in WHERE effectively turns a LEFT JOIN into an INNER JOIN. Always put LEFT JOIN conditions in the ON clause. If the condition is in WHERE, users with no first-month login (NULL rows) disappear and the cohort size is underestimated.
COUNT(DISTINCT) automatically excludes NULL: COUNT(DISTINCT e.user_id) automatically skips rows where e.user_id is NULL. This correctly includes users with no first-month login in the denominator while excluding them from the numerator.
DATE_TRUNC vs DATE_FORMAT: PostgreSQL's DATE_TRUNC('month', col) returns the first day of the month (a timestamp). MySQL's DATE_FORMAT(col, '%Y-%m-01') and BigQuery's DATE_TRUNC(col, MONTH) express the same concept with different syntax. Rounding to month granularity is essential to cohort analysis in every database.
ANTI-PATTERNS
Put the date filter in WHERE (turning LEFT JOIN into INNER JOIN): Writing LEFT JOIN login_events e ON e.user_id = u.user_id WHERE DATE_TRUNC(...) removes NULL rows in WHERE. Users without a first-month login disappear from the count, unintentionally shrinking the cohort size.
Calculate cohort size with INNER JOIN: With INNER JOIN, only users who logged in during the first month are joined, so cohort_size becomes the same as active_in_first_month. Cohort analysis should start with every user and use LEFT JOIN.
FIELD NOTE: When to use cohort analysis
The greatest value of cohort analysis is that it lets you compare the effect of initiatives across user generations. For example, compare first-month activity for the cohort from the month an onboarding flow was improved (the March 2024 cohort) with the January and February cohorts from before the improvement to quantify its effect. Splitting cohorts by acquisition channel (SEO or paid advertising) or pricing plan also reveals which user segments become active over the long term.
QUESTION 2
Retention Analysis — Calculate next-month retention with CTE + INTERVAL '1 month'
CTEINTERVALRetention analysisNext-month retention
Background

Retention analysis measures whether users who registered in a given month continue using the service in later months. The number of users active in the month after the cohort month divided by the cohort size is the Month 1 retention rate.

MetricFormulaPractical benchmark (SaaS)
Month 1 RetentionNext-month active users / cohort size40–60% is strong
Month 3 RetentionActive users three months later / cohort size20–40% is strong
Adding an INTERVAL: cohort_month + INTERVAL '1 month' is PostgreSQL's standard syntax for adding a month. It can be added to the date returned by DATE_TRUNC('month', ...). In BigQuery, write DATE_ADD(cohort_month, INTERVAL 1 MONTH).
Problem

Using the users and login_events tables, calculate the next-month retention rate for each registration-month cohort. Return cohort_month, cohort_size, retained_month1, retention_rate_pct (rounded to one decimal place), ordered by cohort_month ascending.

Tables used
▸ users (6 rows)
user_idregistered_at
12024-01-10
22024-01-15
32024-01-22
42024-02-05
52024-02-14
62024-02-20
▸ login_events (8 rows)
user_idevent_date
12024-01-12
22024-01-18
32024-01-25
12024-02-05
32024-02-10
42024-02-07
42024-03-02
62024-02-22

※ January cohort: users 1 and 3 were active in February (retained) / February cohort: only user4 was active in March

Expected Output

Expected output (cohort_month ascending):

cohort_monthcohort_sizeretained_month1retention_rate_pct
2024-01-013266.7
2024-02-013133.3

January cohort (users 1, 2, and 3) → 2 users, users 1 and 3, logged in during the following February = 66.7%. February cohort (users 4, 5, and 6) → only user4 logged in during the following March = 33.3%.

Model Answer
WITH cohorts AS (
  -- Round each registration date to the first of its month and define the cohort
  SELECT
    user_id,
    DATE_TRUNC('month', registered_at)::date  AS cohort_month
  FROM  users
),
activity AS (
  -- Get the distinct list of months in which each user was active
  SELECT DISTINCT
    user_id,
    DATE_TRUNC('month', event_date)::date    AS active_month
  FROM  login_events
)
SELECT
  c.cohort_month,
  COUNT(DISTINCT c.user_id)  AS cohort_size,
  COUNT(DISTINCT a.user_id)  AS retained_month1,
  ROUND(
    COUNT(DISTINCT a.user_id) * 100.0 /
    COUNT(DISTINCT c.user_id), 1
  ) AS retention_rate_pct
FROM   cohorts c
LEFT JOIN activity a
  ON  a.user_id     = c.user_id
  AND a.active_month = c.cohort_month + INTERVAL '1 month'  -- Join only the following month
GROUP BY c.cohort_month
ORDER BY c.cohort_month;

/*
  Logical execution order:
  1. CTE cohorts            → Define the CTE
  2. CTE activity           → Define the CTE
  3. FROM cohorts c
     LEFT JOIN activity a   → Join while retaining every left-side row
  4. GROUP BY cohort_month  → Form groups
  5. Evaluate aggregates     → COUNT(DISTINCT ...)
  6. SELECT                 → Format values
*/
Explanation (table transitions & key points)
WITH cohorts AS ( SELECT user_id, DATE_TRUNC('month', registered_at)::date AS cohort_month FROM users ), activity AS ( SELECT DISTINCT user_id, DATE_TRUNC('month', event_date)::date AS active_month FROM login_events ) SELECT c.cohort_month, COUNT(DISTINCT c.user_id) AS cohort_size, COUNT(DISTINCT a.user_id) AS retained_month1, ROUND( COUNT(DISTINCT a.user_id) * 100.0 / COUNT(DISTINCT c.user_id), 1 ) AS retention_rate_pct FROM cohorts c LEFT JOIN activity a ON a.user_id = c.user_id AND a.active_month = c.cohort_month + INTERVAL '1 month' GROUP BY c.cohort_month ORDER BY c.cohort_month;
LEGEND
Rows read / loaded
① CTE cohorts — Round registration dates to the first of the month
DATE_TRUNC('month', registered_at)::date AS cohort_monthRound users.registered_at down to the first day of its month with DATE_TRUNC('month', ...). This prepares users registered in the same month to be grouped into one cohort.
1 / 5
user_idregistered_at▸ cohort_month
12024-01-102024-01-01
22024-01-152024-01-01
32024-01-222024-01-01
42024-02-052024-02-01
52024-02-142024-02-01
62024-02-202024-02-01
cohorts CTE: 6 rows
LEARNING POINTS
Specify month offsets with INTERVAL: cohort_month + INTERVAL '1 month' lets you change the retention period flexibly. Use INTERVAL '2 month' for Month 2 retention or INTERVAL '3 month' for Month 3 retention; changing the number is enough to calculate any period.
DISTINCT in the activity CTE protects data quality: Even if a user logs in multiple times in the same month, DISTINCT consolidates the records into one row. COUNT(DISTINCT a.user_id) would still work without DISTINCT, but deduplicating at the CTE level improves join efficiency and prevents mistaken interpretations.
Extend this to a cohort retention table: To output Month 1 through N retention at once, combine the query with UNNEST(ARRAY[1,2,3]) AS offset and use a.active_month = cohort_month + (offset || ' month')::INTERVAL to generate every period in one query.
ANTI-PATTERNS
Put the active_month condition in WHERE: Writing WHERE a.active_month = cohort_month + INTERVAL '1 month' turns the LEFT JOIN into an INNER JOIN and removes users with no activity the following month. cohort_size then equals the next-month active count and retention is incorrectly always 100%.
Join every month after registration instead of only the following month: If the ON clause uses a.active_month >= cohort_month + INTERVAL '1 month', activity from later months is also joined and users are counted repeatedly. For Month 1 retention, specify the following month exactly with =.
FIELD NOTE: Interpreting retention rates and benchmarks
For SaaS products, Month 1 retention of 40% or more is generally considered strong. Plotting retention curves by cohort makes it easy to see which registration channels or plans produce users who stick around. A cohort with declining retention may have been affected by an external factor such as a feature release or price change; comparing the cohort with the initiative timeline is the first step toward improvement.
QUESTION 3
Funnel Analysis — Calculate step conversion rates with FILTER(WHERE ...)
FILTER WHERECOUNT DISTINCTFunnel analysisCVR calculation
Background

Funnel analysis quantifies how users progress through multiple steps such as “view → signup → purchase” toward a goal. PostgreSQL's FILTER (WHERE ...) clause aggregates counts for each step more concisely than CASE WHEN.

-- FILTER clause (recommended)
COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'signup')

-- CASE WHEN (equivalent but more verbose)
COUNT(DISTINCT CASE WHEN event_type = 'signup' THEN user_id END)
Why count each step independently: In a funnel, do not assume that a user who reaches step3 also passed through steps 1 and 2; COUNT the event rows corresponding to each step independently. In practice, users may skip a step, such as completing a purchase without adding an item to the cart, so independent DISTINCT counts are accurate.
Problem

Using the funnel_events table, calculate the number of users reaching each step and the conversion rates for a four-step funnel (page view → signup → add to cart → purchase). Aggregate each step in a CTE and calculate CVR in the outer query. Return page_view, signup, add_cart, purchase, view_to_signup_pct, signup_to_cart_pct, cart_to_purchase_pct (each percentage rounded to one decimal place).

Table used
▸ funnel_events (14 rows)
user_idevent_typeevent_date
1page_view2024-01-10
2page_view2024-01-10
3page_view2024-01-10
4page_view2024-01-11
5page_view2024-01-11
1signup2024-01-10
2signup2024-01-11
3signup2024-01-12
4signup2024-01-13
2add_cart2024-01-12
3add_cart2024-01-13
4add_cart2024-01-13
3purchase2024-01-15
4purchase2024-01-16
Expected Output

Expected output (one row):

page_viewsignupadd_cartpurchaseview_to_signup_pctsignup_to_cart_pctcart_to_purchase_pct
543280.075.066.7

page_view (5) → signup (4): 80% / signup (4) → add_cart (3): 75% / add_cart (3) → purchase (2): 66.7% — quantify the drop-off at each step.

Model Answer
WITH counts AS (
  -- Consolidate the users reaching each step into one row
  SELECT
    COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'page_view') AS s1,
    COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'signup')    AS s2,
    COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'add_cart')  AS s3,
    COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'purchase')  AS s4
  FROM   funnel_events
)
SELECT
  s1 AS page_view,
  s2 AS signup,
  s3 AS add_cart,
  s4 AS purchase,
  ROUND(s2 * 100.0 / s1, 1) AS view_to_signup_pct,    -- step 1→2 conversion rate
  ROUND(s3 * 100.0 / s2, 1) AS signup_to_cart_pct,    -- step 2→3 conversion rate
  ROUND(s4 * 100.0 / s3, 1) AS cart_to_purchase_pct   -- step 3→4 conversion rate
FROM   counts;

/*
  Logical execution order:
  1. CTE counts        → Define the CTE
  2. Evaluate aggregates → Aggregate with COUNT(DISTINCT ...) FILTER
  3. Evaluate outer query → Format values and select columns
*/
Explanation (table transitions & key points)
WITH counts AS ( SELECT COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'page_view') AS s1, COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'signup') AS s2, COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'add_cart') AS s3, COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'purchase') AS s4 FROM funnel_events ) SELECT s1 AS page_view, s2 AS signup, s3 AS add_cart, s4 AS purchase, ROUND(s2 * 100.0 / s1, 1) AS view_to_signup_pct, ROUND(s3 * 100.0 / s2, 1) AS signup_to_cart_pct, ROUND(s4 * 100.0 / s3, 1) AS cart_to_purchase_pct FROM counts;
LEGEND
Rows read / loaded
① FROM funnel_events (14 rows)
FROM funnel_eventsRead the 14 rows in funnel_events. Four event_type values correspond to the four funnel steps. At this point every row is included.
1 / 4
user_idevent_typeevent_date
1page_view2024-01-10
2page_view2024-01-10
3page_view2024-01-10
4page_view2024-01-11
5page_view2024-01-11
1signup2024-01-10
2signup2024-01-11
3signup2024-01-12
4signup2024-01-13
2add_cart2024-01-12
3add_cart2024-01-13
4add_cart2024-01-13
3purchase2024-01-15
4purchase2024-01-16
Read 14 rows (4 event types × the funnel steps)
LEARNING POINTS
FILTER (WHERE ...) is standard from PostgreSQL 9.4 onward: It is more readable than CASE WHEN ... END and gives the query optimizer independently optimizable expressions. BigQuery and DuckDB support the same syntax. MySQL versions below 8.0 do not support it, so use CASE WHEN there.
Independent step counts capture skips accurately: If a user skips add_cart and purchases directly, s4 still counts that user. To strictly track only users who passed every step in order, use another approach: make each step a subquery and chain them with INNER JOIN (a strict funnel).
When to use overall CVR: This quiz calculates step-to-step conversion rates (step CVR). The final rate relative to step1 (overall CVR) is ROUND(s4 * 100.0 / s1, 1). Step CVR identifies where users drop off; overall CVR evaluates total efficiency from advertising to purchase.
ANTI-PATTERNS
Count duplicate users with COUNT(*) FILTER: COUNT(*) FILTER (WHERE event_type = 'signup') counts duplicate events when one user has multiple signup events. Use COUNT(DISTINCT user_id) to make the funnel user-based.
Leave division by zero unguarded: If the number of users in an upper step is 0, ROUND(s2 * 100.0 / s1, 1) raises a division-by-zero error. In production, guard with NULLIF(s1, 0) or use CASE WHEN s1 = 0 THEN NULL ELSE ROUND(...) END.
FIELD NOTE: The funnel analysis improvement cycle
The purpose of funnel analysis is not merely to produce numbers; it is to identify the bottleneck with the greatest improvement impact. If cart-to-purchase CVR is 66.7%, for example, concrete initiatives include improving checkout UX, reviewing shipping costs, and sending reminder emails. Combine funnel CVR with A/B tests and compare cohorts before and after an initiative to prove its impact quantitatively.
QUESTION 4
Active User Analysis — Calculate stickiness with DAU/MAU
CTECOUNT DISTINCTDAU / MAUStickiness
Background

Stickiness is calculated as average DAU (daily active users) ÷ MAU (monthly active users). It indicates how frequently users use the service.

MetricDefinitionCalculation
DAUUnique users with a session on a given dayCOUNT(DISTINCT user_id) per date
MAUUnique users with a session during the monthCOUNT(DISTINCT user_id) per month
StickinessAverage DAU ÷ MAU (higher means more habitual use)AVG(DAU) / MAU × 100
Two aggregation stages are required: First aggregate daily DAU in a CTE, then average DAU by month. Trying to write both stages in one query disrupts the aggregation order, so the standard approach is to calculate them step by step with CTEs.
Problem

Using the user_sessions table, calculate monthly avg_dau, MAU, and stickiness. Return month, avg_dau, mau, stickiness_pct (each value rounded to one decimal place), ordered by month ascending.

Table used
▸ user_sessions (10 rows)
user_idsession_date
12024-01-01
22024-01-01
32024-01-01
12024-01-15
42024-01-15
22024-01-20
12024-02-01
22024-02-01
52024-02-10
12024-02-20

※ January: daily DAU (day 01=3, day 15=2, day 20=1), MAU=4 / February: daily DAU (day 01=2, day 10=1, day 20=1), MAU=3

Expected Output

Expected output (month ascending):

monthavg_daumaustickiness_pct
2024-01-012.0450.0
2024-02-011.3343.3

January: avg_dau=(3+2+1)/3=2.0, MAU=4 → stickiness=50%. February: avg_dau=(2+1+1)/3≈1.3, MAU=3 → stickiness=43.3%.

Model Answer
WITH daily AS (
  -- Aggregate daily DAU (unique users)
  SELECT
    DATE_TRUNC('month', session_date)::date  AS month,
    session_date,
    COUNT(DISTINCT user_id)               AS dau
  FROM   user_sessions
  GROUP BY month, session_date
),
monthly AS (
  -- Aggregate monthly MAU (unique users within the month)
  SELECT
    DATE_TRUNC('month', session_date)::date  AS month,
    COUNT(DISTINCT user_id)               AS mau
  FROM   user_sessions
  GROUP BY month
)
SELECT
  m.month,
  ROUND(AVG(d.dau), 1)                        AS avg_dau,
  m.mau,
  ROUND(AVG(d.dau) * 100.0 / m.mau, 1)       AS stickiness_pct  -- average DAU / MAU
FROM   monthly m
JOIN   daily   d USING (month)
GROUP BY m.month, m.mau
ORDER BY m.month;

/*
  Logical execution order:
  1. CTE daily            → Define the CTE
  2. CTE monthly          → Define the CTE
  3. FROM monthly m
     JOIN daily d         → Join matching rows only
  4. GROUP BY m.month     → Form groups
  5. Evaluate aggregates  → AVG(d.dau)
  6. SELECT               → Format values
  7. ORDER BY m.month     → Sort and output
*/
Explanation (table transitions & key points)
WITH daily AS ( SELECT DATE_TRUNC('month', session_date)::date AS month, session_date, COUNT(DISTINCT user_id) AS dau FROM user_sessions GROUP BY month, session_date ), monthly AS ( SELECT DATE_TRUNC('month', session_date)::date AS month, COUNT(DISTINCT user_id) AS mau FROM user_sessions GROUP BY month ) SELECT m.month, ROUND(AVG(d.dau), 1) AS avg_dau, m.mau, ROUND(AVG(d.dau) * 100.0 / m.mau, 1) AS stickiness_pct FROM monthly m JOIN daily d USING (month) GROUP BY m.month, m.mau ORDER BY m.month;
LEGEND
Rows read / loaded
① CTE daily — Aggregate DAU by day
GROUP BY month, session_date → COUNT(DISTINCT user_id) AS dauGroup user_sessions by month and date, then aggregate the number of unique users (DAU) for each day. January 1 has users 1, 2, and 3 (3 users); January 15 has users 1 and 4 (2 users); January 20 has user2 (1 user).
1 / 4
monthsession_date▸ dau
2024-01-012024-01-013
2024-01-012024-01-152
2024-01-012024-01-201
2024-02-012024-02-012
2024-02-012024-02-101
2024-02-012024-02-201
daily CTE: 6 rows (month × date combinations)
LEARNING POINTS
Why DAU and MAU cannot be calculated together in one aggregation: DAU is counted by date while MAU is counted by month, so their aggregation grains differ. You cannot specify both grains simultaneously in GROUP BY; staged aggregation with CTEs is required.
Remove duplicate users with COUNT(DISTINCT) for MAU: The monthly CTE groups user_sessions by month, but even if a user has multiple sessions in the month, COUNT(DISTINCT user_id) counts that user once. MAU correctly means the number of unique users active at least once during the month.
What 50% stickiness means: Stickiness=50% means users use the service on average one out of every two days in the month. Social networks such as Facebook often measure 50–60%, while SaaS tools are commonly around 20–40%. A cohort with falling stickiness may be churning before usage becomes habitual, which helps prioritize product improvements.
ANTI-PATTERNS
Calculate AVG(dau) without GROUP BY: A nested aggregate such as SELECT AVG(COUNT(DISTINCT user_id)) cannot be written directly in PostgreSQL. First expand DAU into rows with a CTE or subquery, then apply AVG in a two-stage design.
Include duplicate sessions with COUNT(*): When one user has multiple sessions in a day, COUNT(*) counts sessions, not people, so it is not DAU. Active-user analysis should always use COUNT(DISTINCT user_id).
FIELD NOTE: Multi-axis analysis with WAU and stickiness
Combining WAU (weekly active users) with DAU/MAU gives a more detailed view of usage frequency. DAU/WAU shows how many days a user uses the product within a week; WAU/MAU shows how many weeks within a month. The right metric depends on whether the product is used daily (social networks and messengers) or once or twice a week (news and reporting tools). Set a stickiness target that matches the expected usage frequency of your product.
QUESTION 5
Churn Analysis — Identify users who dropped off for a month or more with LEFT JOIN IS NULL
LEFT JOIN IS NULLCTEChurn analysisIdentify churned users
Background

Churn analysis identifies users who were active in the previous month but are not active in the current month. In SQL, the LEFT JOIN IS NULL pattern (an anti-join) is both readable and efficient.

MethodSyntaxCharacteristics
LEFT JOIN IS NULLLEFT JOIN ... WHERE b.key IS NULLMost readable and easy to optimize ★ Recommended
NOT EXISTSWHERE NOT EXISTS (SELECT 1 FROM ...)Correlated subquery; useful for large tables
NOT INWHERE user_id NOT IN (SELECT ...)Can exclude every row when NULL is present ✗ Not recommended
How LEFT JOIN IS NULL works: LEFT JOIN keeps rows that have no match in the right table and sets the right-table columns to NULL. WHERE right_table.key IS NULL then extracts only those unmatched rows. This is the standard pattern for identifying churned users: “active in the previous month and inactive in the following month.”
Problem

Using the login_events table, identify the users who were active in January 2024 but did not log in during February 2024 (churned). Return user_id, active_month, is_churned, ordered by user_id ascending.

Table used
▸ login_events (6 rows)
user_idevent_date
12024-01-12
22024-01-18
32024-01-25
12024-02-05
32024-02-10
42024-02-08

※ January active: users 1, 2, and 3 / February active: users 1, 3, and 4 (user4 was not present in January)

Expected Output

Expected output (user_id ascending):

user_idactive_monthis_churned
12024-01false
22024-01true
32024-01false

user2 was active in January but did not log in during February, so the user churned. users 1 and 3 continued into February. user4 was not in January and is outside the target population.

Model Answer
WITH jan_active AS (
  -- Extract users active in January 2024 (deduplicate them)
  SELECT DISTINCT user_id
  FROM   login_events
  WHERE  DATE_TRUNC('month', event_date) = '2024-01-01'
),
feb_active AS (
  -- Extract users active in February 2024 (deduplicate them)
  SELECT DISTINCT user_id
  FROM   login_events
  WHERE  DATE_TRUNC('month', event_date) = '2024-02-01'
)
SELECT
  j.user_id,
  '2024-01'                                             AS active_month,
  CASE WHEN f.user_id IS NULL THEN true ELSE false END  AS is_churned  -- NULL = no next-month login = churn
FROM   jan_active  j
LEFT JOIN feb_active f USING (user_id)   -- A user absent in February has f.user_id = NULL
ORDER BY j.user_id;

/*
  Logical execution order:
  1. CTE jan_active        → Define the CTE
  2. CTE feb_active        → Define the CTE
  3. FROM jan_active j
     LEFT JOIN feb_active  → Join while retaining every left-side row
  4. SELECT                → Evaluate columns (classify with CASE)
  5. ORDER BY j.user_id    → Sort and output
*/
Explanation (table transitions & key points)
WITH jan_active AS ( SELECT DISTINCT user_id FROM login_events WHERE DATE_TRUNC('month', event_date) = '2024-01-01' ), feb_active AS ( SELECT DISTINCT user_id FROM login_events WHERE DATE_TRUNC('month', event_date) = '2024-02-01' ) SELECT j.user_id, '2024-01' AS active_month, CASE WHEN f.user_id IS NULL THEN true ELSE false END AS is_churned FROM jan_active j LEFT JOIN feb_active f USING (user_id) ORDER BY j.user_id;
LEGEND
Rows read / loaded
✓ pass
✗ excluded
① CTE jan_active — Extract January active users
WHERE DATE_TRUNC('month', event_date) = '2024-01-01'Filter login_events to January 2024 with WHERE and deduplicate users with DISTINCT. The three users who logged in during January—users 1, 2, and 3—are extracted.
1 / 4
user_idevent_dateJanuary filter?
12024-01-12✓ → jan_active
22024-01-18✓ → jan_active
32024-01-25✓ → jan_active
12024-02-05✗ Outside scope
32024-02-10✗ Outside scope
42024-02-08✗ Outside scope
jan_active CTE: 3 rows {user1, user2, user3}
LEARNING POINTS
LEFT JOIN IS NULL is the standard anti-join pattern: The operation of finding records that exist in A but not in B is called an anti-join. LEFT JOIN + WHERE b.key IS NULL is easy for the optimizer to rewrite as a Hash Anti Join or Merge Anti Join and usually performs well.
A NULL in NOT IN can remove every row: With WHERE user_id NOT IN (SELECT user_id FROM feb_active), if feb_active contains even one NULL user_id, the entire WHERE expression becomes false and returns zero rows. LEFT JOIN IS NULL or NOT EXISTS is safer than NOT IN.
Aggregate churn rate from is_churned: Wrap this query in an outer query and use ROUND(COUNT(*) FILTER (WHERE is_churned) * 100.0 / COUNT(*), 1) to calculate the January-to-February churn rate as one number. In this example, 1/3 ≈ 33.3% churn.
ANTI-PATTERNS
Allow NULL into a NOT IN subquery: If the subquery in WHERE user_id NOT IN (SELECT user_id FROM feb_active WHERE ...) returns NULL, NOT IN evaluates user_id != NULL as UNKNOWN for every row. The result is a critical zero-row bug. Always use LEFT JOIN IS NULL or NOT EXISTS.
Try to classify churn with INNER JOIN: Finding users present in both months with INNER JOIN and then treating “the remainder” as churn requires two stages and is unnecessarily verbose. LEFT JOIN IS NULL completes the task in one query, making NULL's meaning—“absent, therefore churned”—explicit.
FIELD NOTE: Applying the analysis to churn prediction
The purpose of churn analysis is not only to detect users after they leave, but also to prevent the departure. Feed behavioral patterns of churned users—such as declining recent login frequency or stopping use of a particular feature—as features into a machine-learning model to calculate a churn prediction score. In SQL, generalize LEAD(active_month) OVER (PARTITION BY user_id ORDER BY active_month) to find rows where the next active month is not the following month, dynamically detecting churn for any period and cohort.