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
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.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.
| user_id | registered_at |
|---|---|
| 1 | 2024-01-10 |
| 2 | 2024-01-15 |
| 3 | 2024-01-22 |
| 4 | 2024-02-05 |
| 5 | 2024-02-14 |
| 6 | 2024-02-20 |
| user_id | event_date |
|---|---|
| 1 | 2024-01-12 |
| 2 | 2024-01-18 |
| 3 | 2024-02-03 |
| 4 | 2024-02-07 |
| 5 | 2024-03-05 |
| 6 | 2024-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 (cohort_month ascending):
| cohort_month | cohort_size | active_in_first_month | first_month_active_pct |
|---|---|---|---|
| 2024-01-01 | 3 | 2 | 66.7 |
| 2024-02-01 | 3 | 2 | 66.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.
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 */
LEGEND
① 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.| user_id | registered_at |
|---|---|
| 1 | 2024-01-10 |
| 2 | 2024-01-15 |
| 3 | 2024-01-22 |
| 4 | 2024-02-05 |
| 5 | 2024-02-14 |
| 6 | 2024-02-20 |
| user_id | event_date |
|---|---|
| 1 | 2024-01-12 |
| 2 | 2024-01-18 |
| 3 | 2024-02-03 |
| 4 | 2024-02-07 |
| 5 | 2024-03-05 |
| 6 | 2024-02-22 |
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('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.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.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.
| Metric | Formula | Practical benchmark (SaaS) |
|---|---|---|
| Month 1 Retention | Next-month active users / cohort size | 40–60% is strong |
| Month 3 Retention | Active users three months later / cohort size | 20–40% is strong |
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).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.
| user_id | registered_at |
|---|---|
| 1 | 2024-01-10 |
| 2 | 2024-01-15 |
| 3 | 2024-01-22 |
| 4 | 2024-02-05 |
| 5 | 2024-02-14 |
| 6 | 2024-02-20 |
| user_id | event_date |
|---|---|
| 1 | 2024-01-12 |
| 2 | 2024-01-18 |
| 3 | 2024-01-25 |
| 1 | 2024-02-05 |
| 3 | 2024-02-10 |
| 4 | 2024-02-07 |
| 4 | 2024-03-02 |
| 6 | 2024-02-22 |
※ January cohort: users 1 and 3 were active in February (retained) / February cohort: only user4 was active in March
Expected output (cohort_month ascending):
| cohort_month | cohort_size | retained_month1 | retention_rate_pct |
|---|---|---|---|
| 2024-01-01 | 3 | 2 | 66.7 |
| 2024-02-01 | 3 | 1 | 33.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%.
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 */
LEGEND
① 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.| user_id | registered_at | ▸ cohort_month |
|---|---|---|
| 1 | 2024-01-10 | 2024-01-01 |
| 2 | 2024-01-15 | 2024-01-01 |
| 3 | 2024-01-22 | 2024-01-01 |
| 4 | 2024-02-05 | 2024-02-01 |
| 5 | 2024-02-14 | 2024-02-01 |
| 6 | 2024-02-20 | 2024-02-01 |
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.UNNEST(ARRAY[1,2,3]) AS offset and use a.active_month = cohort_month + (offset || ' month')::INTERVAL to generate every period in one query.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%.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 =.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)
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).
| user_id | event_type | event_date |
|---|---|---|
| 1 | page_view | 2024-01-10 |
| 2 | page_view | 2024-01-10 |
| 3 | page_view | 2024-01-10 |
| 4 | page_view | 2024-01-11 |
| 5 | page_view | 2024-01-11 |
| 1 | signup | 2024-01-10 |
| 2 | signup | 2024-01-11 |
| 3 | signup | 2024-01-12 |
| 4 | signup | 2024-01-13 |
| 2 | add_cart | 2024-01-12 |
| 3 | add_cart | 2024-01-13 |
| 4 | add_cart | 2024-01-13 |
| 3 | purchase | 2024-01-15 |
| 4 | purchase | 2024-01-16 |
Expected output (one row):
| page_view | signup | add_cart | purchase | view_to_signup_pct | signup_to_cart_pct | cart_to_purchase_pct |
|---|---|---|---|---|---|---|
| 5 | 4 | 3 | 2 | 80.0 | 75.0 | 66.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.
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 */
LEGEND
① 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.| user_id | event_type | event_date |
|---|---|---|
| 1 | page_view | 2024-01-10 |
| 2 | page_view | 2024-01-10 |
| 3 | page_view | 2024-01-10 |
| 4 | page_view | 2024-01-11 |
| 5 | page_view | 2024-01-11 |
| 1 | signup | 2024-01-10 |
| 2 | signup | 2024-01-11 |
| 3 | signup | 2024-01-12 |
| 4 | signup | 2024-01-13 |
| 2 | add_cart | 2024-01-12 |
| 3 | add_cart | 2024-01-13 |
| 4 | add_cart | 2024-01-13 |
| 3 | purchase | 2024-01-15 |
| 4 | purchase | 2024-01-16 |
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.ROUND(s4 * 100.0 / s1, 1). Step CVR identifies where users drop off; overall CVR evaluates total efficiency from advertising to purchase.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.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.Stickiness is calculated as average DAU (daily active users) ÷ MAU (monthly active users). It indicates how frequently users use the service.
| Metric | Definition | Calculation |
|---|---|---|
| DAU | Unique users with a session on a given day | COUNT(DISTINCT user_id) per date |
| MAU | Unique users with a session during the month | COUNT(DISTINCT user_id) per month |
| Stickiness | Average DAU ÷ MAU (higher means more habitual use) | AVG(DAU) / MAU × 100 |
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.
| user_id | session_date |
|---|---|
| 1 | 2024-01-01 |
| 2 | 2024-01-01 |
| 3 | 2024-01-01 |
| 1 | 2024-01-15 |
| 4 | 2024-01-15 |
| 2 | 2024-01-20 |
| 1 | 2024-02-01 |
| 2 | 2024-02-01 |
| 5 | 2024-02-10 |
| 1 | 2024-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 (month ascending):
| month | avg_dau | mau | stickiness_pct |
|---|---|---|---|
| 2024-01-01 | 2.0 | 4 | 50.0 |
| 2024-02-01 | 1.3 | 3 | 43.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%.
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 */
LEGEND
① 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).| month | session_date | ▸ dau |
|---|---|---|
| 2024-01-01 | 2024-01-01 | 3 |
| 2024-01-01 | 2024-01-15 | 2 |
| 2024-01-01 | 2024-01-20 | 1 |
| 2024-02-01 | 2024-02-01 | 2 |
| 2024-02-01 | 2024-02-10 | 1 |
| 2024-02-01 | 2024-02-20 | 1 |
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.COUNT(*) counts sessions, not people, so it is not DAU. Active-user analysis should always use COUNT(DISTINCT user_id).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.
| Method | Syntax | Characteristics |
|---|---|---|
| LEFT JOIN IS NULL | LEFT JOIN ... WHERE b.key IS NULL | Most readable and easy to optimize ★ Recommended |
| NOT EXISTS | WHERE NOT EXISTS (SELECT 1 FROM ...) | Correlated subquery; useful for large tables |
| NOT IN | WHERE user_id NOT IN (SELECT ...) | Can exclude every row when NULL is present ✗ Not recommended |
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.”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.
| user_id | event_date |
|---|---|
| 1 | 2024-01-12 |
| 2 | 2024-01-18 |
| 3 | 2024-01-25 |
| 1 | 2024-02-05 |
| 3 | 2024-02-10 |
| 4 | 2024-02-08 |
※ January active: users 1, 2, and 3 / February active: users 1, 3, and 4 (user4 was not present in January)
Expected output (user_id ascending):
| user_id | active_month | is_churned |
|---|---|---|
| 1 | 2024-01 | false |
| 2 | 2024-01 | true |
| 3 | 2024-01 | false |
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.
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 */
LEGEND
① 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.| user_id | event_date | January filter? |
|---|---|---|
| 1 | 2024-01-12 | ✓ → jan_active |
| 2 | 2024-01-18 | ✓ → jan_active |
| 3 | 2024-01-25 | ✓ → jan_active |
| 1 | 2024-02-05 | ✗ Outside scope |
| 3 | 2024-02-10 | ✗ Outside scope |
| 4 | 2024-02-08 | ✗ Outside scope |
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.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.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.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.