Skip to course content
Free SQL course

SQL for Data Analysis and AI

Unit 08.04: Cohorts: grouping by a fixed origin period

Unit ID: SQL-M08-U05 - Estimated active time: 14-17 minutes Objective: build a cohort by a fixed starting period and explain what makes cohorts comparable.

A cohort is defined by when you started, not what you did

Group customers by signup month, and each customer stays in that group forever:

SELECT DATE_TRUNC('month', signed_up) AS cohort_month,
       COUNT(*)                       AS customers
FROM customers
GROUP BY cohort_month
ORDER BY cohort_month
LIMIT 5;
-- 2025-01-01 | 309
-- 2025-02-01 | 280
-- 2025-03-01 | 310
-- 2025-04-01 | 300
-- 2025-05-01 | 310

The fixed origin is what makes the groups comparable. If customers could move between cohorts based on recent behaviour, you could not attribute a difference to when they joined.

Cohort plus outcome

The useful shape combines the cohort with something that happened later:

SELECT DATE_TRUNC('month', c.signed_up)             AS cohort_month,
       COUNT(*)                                     AS customers,
       COUNT(o.order_id)                            AS orders,
       ROUND(100.0 * COUNT(DISTINCT o.customer_id)
             / COUNT(DISTINCT c.customer_id), 1)    AS pct_ordered
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
GROUP BY cohort_month
ORDER BY cohort_month;

Two deliberate choices here, both from earlier modules:

every cohort would show 100% conversion.

(Module 4).

The comparability trap

Later cohorts have had less time to act. A cohort that signed up last month cannot show twelve-month retention. Comparing "percentage who ordered" across cohorts of different ages measures elapsed time, not quality.

The fix is to compare at a fixed age - orders within 30 days of signup, for every cohort:

SELECT DATE_TRUNC('month', c.signed_up) AS cohort_month,
       COUNT(DISTINCT c.customer_id)    AS customers,
       COUNT(DISTINCT CASE
         WHEN o.placed_at < c.signed_up + INTERVAL 30 DAY
         THEN o.customer_id END)        AS ordered_within_30d
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
GROUP BY cohort_month
ORDER BY cohort_month;

Now every cohort is measured over the same window, and the comparison is about the cohorts rather than the calendar.

Practice

Why would an inner join make every cohort's conversion rate look identical, and what would that identical value be?

Check your answer

An inner join keeps only customers who have at least one order, so the numerator and denominator become the same set. Every cohort would show 100%.

The bug is invisible in the output - 100% across the board looks like a formatting artefact rather than a join error, which is why Module 5's "which rows does this join drop" question matters here.

Takeaway

Fix the origin period and hold it constant. Compare cohorts at the same age, not on the same calendar date, or you are measuring how long ago they joined.

---