0% completed
Retention and Cohort Tables
On This Page
The Tables
Cohorts From Day Numbers
Week-N Retention
The Cohort Table
Cohorts Too Young to Measure
Choosing the Denominator
Day-N Retention
Practice: Build a Two-Week Cohort Table
Using This in an Interview
Key Takeaways
Practice Questions
A meal-kit service signed up 4 customers in its first week. In the third week after that first week, 3 of the 4 received a box, so 3 / 4 = 75 percent of the group was active. The service signed up 4 more customers in its second week, and in the third week after that week none of them received a box, which is 0 / 4 = 0 percent. The two figures are comparable because each one is measured the same number of weeks after its group started.
Each figure describes a group of customers who started in the same period (a cohort), and the share of that group still active N periods later (its retention for period N). A table with one cohort on each row and one period on each column (a cohort table) shows all of these figures at once.
The Tables
subscribers has one row per customer with the signup day, and deliveries has one row per box with its delivery_id, subscriber_id, and day_no. Days are integer day numbers, and the data ends on day 35. To save space, the table below lists each subscriber's delivery days; deliveries itself has 35 rows.
| subscriber_id | signup_day | days with a delivery |
|---|---|---|
| 101 | 2 | 4, 11, 18, 25, 32 |
| 102 | 3 | 5, 12, 26 |
| 103 | 5 | 6, 13 |
| 104 | 7 | 7, 14, 21, 28, 33, 35 |
| 105 | 9 | 10, 17, 24 |
| 106 | 10 | 11, 25 |
| 116 | 12 | none |
| 107 | 13 | 14 |
| 108 | 15 | 16, 23, 30 |
| 109 | 17 | 19, 26 |
| 110 | 20 | 20, 34 |
| 111 | 21 | 21 |
| 112 | 23 | 24, 31 |
| 113 | 27 | 28 |
| 114 | 31 | 32 |
| 115 | 33 | 35 |
Two rows need attention. Subscriber 116 signed up on day 12 and never received a box, and subscriber 104 received two boxes in the same week, on days 33 and 35. Every query below was run on MySQL 8.4, and the output shown is the real output.
Cohorts From Day Numbers
A signup cohort is the set of subscribers who signed up in the same period, here the same week. Week 1 is days 1 to 7 and week 2 is days 8 to 14, so the week of a day is FLOOR((day_no - 1) / 7) + 1. Subtracting 1 first matters, because FLOOR(day_no / 7) would put day 7 with days 8 to 13.
SELECT FLOOR((signup_day - 1) / 7) + 1 AS cohort_week, COUNT(*) AS subscribers, MIN(signup_day) AS first_signup_day, MAX(signup_day) AS last_signup_day FROM subscribers GROUP BY cohort_week ORDER BY cohort_week;
Output:
+-------------+-------------+------------------+-----------------+ | cohort_week | subscribers | first_signup_day | last_signup_day | +-------------+-------------+------------------+-----------------+ | 1 | 4 | 2 | 7 | | 2 | 4 | 9 | 13 | | 3 | 4 | 15 | 21 | | 4 | 2 | 23 | 27 | | 5 | 2 | 31 | 33 | +-------------+-------------+------------------+-----------------+
The sizes come from subscribers, not from deliveries, for a reason given in the section on denominators.
Week-N Retention
Week-N retention is the share of a cohort that was active in the Nth week after its signup week, where week 0 is the signup week itself. Here, active means at least one delivery. Cohort 1's week 3 is calendar week 4, when subscribers 101, 102, and 104 received boxes: 3 of 4, or 75 percent.
The query reduces the deliveries to one row per subscriber and active week, then counts subscribers per cohort and week number, with a CTE for each step as in CTEs and Recursive CTEs.
WITH cohorts AS ( SELECT subscriber_id, FLOOR((signup_day - 1) / 7) + 1 AS cohort_week FROM subscribers ), activity AS ( -- one row per subscriber and week with at least one box SELECT DISTINCT subscriber_id, FLOOR((day_no - 1) / 7) + 1 AS active_week FROM deliveries ) SELECT c.cohort_week, a.active_week - c.cohort_week AS week_n, COUNT(*) AS active_subscribers FROM cohorts c JOIN activity a ON a.subscriber_id = c.subscriber_id GROUP BY c.cohort_week, week_n ORDER BY c.cohort_week, week_n;
Output:
+-------------+--------+--------------------+ | cohort_week | week_n | active_subscribers | +-------------+--------+--------------------+ | 1 | 0 | 4 | | 1 | 1 | 4 | | 1 | 2 | 2 | | 1 | 3 | 3 | | 1 | 4 | 2 | | 2 | 0 | 3 | | 2 | 1 | 1 | | 2 | 2 | 2 | | 3 | 0 | 4 | | 3 | 1 | 2 | | 3 | 2 | 2 | | 4 | 0 | 2 | | 4 | 1 | 1 | | 5 | 0 | 2 | +-------------+--------+--------------------+
Without the DISTINCT in activity, subscriber 104's two boxes in week 5 would make cohort 1, week 4 read 3 instead of 2. Cohort 2 has no row for week 3, because nobody in it received a box in week 5, and GROUP BY cannot return a group with no rows.
The Cohort Table
Conditional aggregation computes several aggregates in one GROUP BY, where each aggregate reads only the rows that match its own CASE condition. Snowflake and BigQuery document a PIVOT operator that turns rows into columns. MySQL documents no PIVOT and no FILTER clause on aggregates, so each column below is a SUM over a CASE expression.
WITH cohorts AS ( SELECT subscriber_id, FLOOR((signup_day - 1) / 7) + 1 AS cohort_week FROM subscribers ), activity AS ( -- one row per subscriber and week with at least one box SELECT DISTINCT subscriber_id, FLOOR((day_no - 1) / 7) + 1 AS active_week FROM deliveries ) SELECT c.cohort_week, COUNT(DISTINCT c.subscriber_id) AS cohort_size, SUM(CASE WHEN a.active_week - c.cohort_week = 0 THEN 1 ELSE 0 END) AS wk0, SUM(CASE WHEN a.active_week - c.cohort_week = 1 THEN 1 ELSE 0 END) AS wk1, SUM(CASE WHEN a.active_week - c.cohort_week = 2 THEN 1 ELSE 0 END) AS wk2, SUM(CASE WHEN a.active_week - c.cohort_week = 3 THEN 1 ELSE 0 END) AS wk3, SUM(CASE WHEN a.active_week - c.cohort_week = 4 THEN 1 ELSE 0 END) AS wk4 FROM cohorts c LEFT JOIN activity a ON a.subscriber_id = c.subscriber_id GROUP BY c.cohort_week ORDER BY c.cohort_week;
Output:
+-------------+-------------+------+------+------+------+------+ | cohort_week | cohort_size | wk0 | wk1 | wk2 | wk3 | wk4 | +-------------+-------------+------+------+------+------+------+ | 1 | 4 | 4 | 4 | 2 | 3 | 2 | | 2 | 4 | 3 | 1 | 2 | 0 | 0 | | 3 | 4 | 4 | 2 | 2 | 0 | 0 | | 4 | 2 | 2 | 1 | 0 | 0 | 0 | | 5 | 2 | 2 | 0 | 0 | 0 | 0 | +-------------+-------------+------+------+------+------+------+
The LEFT JOIN keeps subscriber 116, who has no activity rows, so cohort 2 still has 4 subscribers. Each SUM adds 1 for rows whose week number matches its column and 0 for the rest, so cohort 2 now shows the missing week 3 as 0.
- Cost of the pivot: the columns are fixed in the query text. A sixth week needs a sixth SUM line.
- When the long form is better: a chart tool or a later join that expects one row per cohort and week can read it directly, so pivot only the final table that people read.
Cohorts Too Young to Measure
Cohorts 2 and 3 both show 0 in week 3, but only the first zero is real. Cohort 2's week 3 is calendar week 5, which has finished. Cohort 3's week 3 is calendar week 6, and the data ends on day 35, the last day of week 5.
A cohort is too young for week N when its signup week plus N is later than the last finished week. Such a cell should be NULL, not zero. The query computes the last finished week as FLOOR(MAX(day_no) / 7), which is 5 for day 35 and would be 4 for day 33, when week 5 is still in progress.
WITH cohorts AS ( SELECT subscriber_id, FLOOR((signup_day - 1) / 7) + 1 AS cohort_week FROM subscribers ), activity AS ( -- one row per subscriber and week with at least one box SELECT DISTINCT subscriber_id, FLOOR((day_no - 1) / 7) + 1 AS active_week FROM deliveries ), counts AS ( SELECT c.cohort_week, COUNT(DISTINCT c.subscriber_id) AS cohort_size, SUM(CASE WHEN a.active_week - c.cohort_week = 0 THEN 1 ELSE 0 END) AS wk0, SUM(CASE WHEN a.active_week - c.cohort_week = 1 THEN 1 ELSE 0 END) AS wk1, SUM(CASE WHEN a.active_week - c.cohort_week = 2 THEN 1 ELSE 0 END) AS wk2, SUM(CASE WHEN a.active_week - c.cohort_week = 3 THEN 1 ELSE 0 END) AS wk3, SUM(CASE WHEN a.active_week - c.cohort_week = 4 THEN 1 ELSE 0 END) AS wk4 FROM cohorts c LEFT JOIN activity a ON a.subscriber_id = c.subscriber_id GROUP BY c.cohort_week ), data_end AS ( -- the last finished week: day 35 ends week 5 SELECT FLOOR(MAX(day_no) / 7) AS last_week FROM deliveries ) SELECT cohort_week, cohort_size, CAST(ROUND(100 * wk0 / cohort_size) AS SIGNED) AS wk0_pct, CASE WHEN cohort_week + 1 <= last_week THEN CAST(ROUND(100 * wk1 / cohort_size) AS SIGNED) END AS wk1_pct, CASE WHEN cohort_week + 2 <= last_week THEN CAST(ROUND(100 * wk2 / cohort_size) AS SIGNED) END AS wk2_pct, CASE WHEN cohort_week + 3 <= last_week THEN CAST(ROUND(100 * wk3 / cohort_size) AS SIGNED) END AS wk3_pct, CASE WHEN cohort_week + 4 <= last_week THEN CAST(ROUND(100 * wk4 / cohort_size) AS SIGNED) END AS wk4_pct FROM counts CROSS JOIN data_end ORDER BY cohort_week;
Output:
+-------------+-------------+---------+---------+---------+---------+---------+ | cohort_week | cohort_size | wk0_pct | wk1_pct | wk2_pct | wk3_pct | wk4_pct | +-------------+-------------+---------+---------+---------+---------+---------+ | 1 | 4 | 100 | 100 | 50 | 75 | 50 | | 2 | 4 | 75 | 25 | 50 | 0 | NULL | | 3 | 4 | 100 | 50 | 50 | NULL | NULL | | 4 | 2 | 100 | 50 | NULL | NULL | NULL | | 5 | 2 | 100 | NULL | NULL | NULL | NULL | +-------------+-------------+---------+---------+---------+---------+---------+
In MySQL, / on two integers returns a decimal, so ROUND and CAST turn it into a whole percent. A CASE with no ELSE returns NULL when its condition is false, which empties the young cells.
A row is one cohort's history, and a column compares cohorts at the same age: in week 1, cohort 1 kept 100 percent, cohort 2 kept 25 percent, and cohorts 3 and 4 kept 50 percent. The NULL cells form a triangle in the lower right, because each later cohort has been observed for one week less.
Choosing the Denominator
The denominator decides what a retention figure means, and three choices give three different numbers for the same cell.
- Cohort size from
subscribers. Cohort 2 in week 1 is 1 / 4 = 25 percent. Every cell in a row shares one denominator, so the row reads as one group over time. - Subscribers who appear in
deliveries. Subscriber 116 never received a box, so the same cell becomes 1 / 3 = 33 percent. It fits only a question about customers who were actually served. - Active subscribers in the previous week. Cohort 1 had 2 active subscribers in week 2 and 3 in week 3, because subscriber 102 skipped week 2 and returned, so the ratio is 3 / 2 = 150 percent. It helps locate one bad week, but it is not a share of the cohort.
State the denominator next to the table. Small cohorts need care too: cohort 4 has 2 subscribers, so one person changes each of its cells by 50 percentage points.
Day-N Retention
Day-N retention is the share of subscribers who were active exactly N days after their own signup day. Because it counts from each signup, each subscriber becomes measurable on a different day: day 7 needs signup_day + 7 to be on or before day 35.
WITH data_end AS ( SELECT MAX(day_no) AS last_day FROM deliveries ) SELECT COUNT(DISTINCT CASE WHEN s.signup_day + 7 <= e.last_day THEN s.subscriber_id END) AS day7_eligible, COUNT(DISTINCT CASE WHEN d.day_no = s.signup_day + 7 THEN s.subscriber_id END) AS day7_active, COUNT(DISTINCT CASE WHEN s.signup_day + 13 <= e.last_day THEN s.subscriber_id END) AS days7to13_eligible, COUNT(DISTINCT CASE WHEN s.signup_day + 13 <= e.last_day AND d.day_no BETWEEN s.signup_day + 7 AND s.signup_day + 13 THEN s.subscriber_id END) AS days7to13_active FROM subscribers s CROSS JOIN data_end e LEFT JOIN deliveries d ON d.subscriber_id = s.subscriber_id;
Output:
+---------------+-------------+--------------------+------------------+ | day7_eligible | day7_active | days7to13_eligible | days7to13_active | +---------------+-------------+--------------------+------------------+ | 14 | 1 | 12 | 7 | +---------------+-------------+--------------------+------------------+
Only subscriber 104 received a box exactly 7 days after signup, so day-7 retention is 1 of 14, about 7 percent. Any delivery from day 7 through day 13 after signup gives 7 of 12, about 58 percent. The eligibility test is repeated in the window's active count; without it, subscriber 112 (signup day 23) would count from its box on day 31, although its window ends on day 36.
A single day suits a product used every day, while a weekly product needs a window at least one week wide. The two ways of counting weeks compare as follows.
| Calendar weeks | Days since signup | |
|---|---|---|
| Week boundaries | The same for everyone | Different for each subscriber |
| Length of week 0 | 1 to 7 days | Always 7 days |
| When a cell can be measured | For the whole cohort at once | For each subscriber separately |
| Fits | Weekly cohort tables | Day-1, day-7, and day-30 product metrics |
- Cost of calendar weeks: week 0 has unequal length. Subscriber 104 signed up on day 7, so its week 0 lasted one day.
- Cost of days since signup: eligibility is per subscriber. A cohort's cell fills in one subscriber at a time, as the query above shows.
Practice: Build a Two-Week Cohort Table
The subscribers and deliveries tables have the columns used in this lesson. Number weeks as FLOOR((day_no - 1) / 7) + 1, so a cohort is the week of signup_day, and a cohort's week N is calendar week cohort_week + N. A subscriber is active in a week when they have at least one delivery in it. A week counts as finished when the latest day_no in deliveries is at or after the week's last day, that is, 7 * week <= MAX(day_no).
Return one row for each cohort whose week 2 has finished, with the columns cohort_week, cohort_size, week1_pct, and week2_pct. cohort_size counts every subscriber in the cohort, including those with no deliveries. Each percentage is the share of cohort_size active in that week, rounded to a whole number with CAST(ROUND(...) AS SIGNED). Sort by cohort_week.
Using This in an Interview
Before writing SQL, state three definitions out loud: what counts as active, how a week is numbered, and which denominator the percentages use. Then build one CTE per step and check one cell by hand, such as cohort 1 in week 3: 3 of 4, or 75 percent.
Meta's guide for its Data Engineer, Product Analytics screen says the SQL part runs on PostgreSQL, though standard ANSI SQL is enough. PostgreSQL truncates integer division, so 100 * 2 / 3 returns 66 there and 66.6667 in MySQL. Writing 100.0 * makes both engines divide as decimals.
- "Why is cohort 3's week 3 empty instead of zero?" Its week 3 is calendar week 6, and the data ends with week 5. A zero would claim that every subscriber left, while NULL says the week has not been observed.
- "How would you write the pivot in Snowflake or BigQuery?" Both document a PIVOT operator that rotates rows into columns, so one PIVOT clause can replace the SUM over CASE columns. The CTE steps stay, because both engines document the WITH clause.
Key Takeaways
- A cohort is a group that started in the same period, computed here from a day number as
FLOOR((day_no - 1) / 7) + 1. - Week-N retention counts distinct subscribers active in the cohort's Nth week, divided by the cohort size from the subscribers table.
- MySQL documents no PIVOT, so a cohort table is conditional aggregation: one SUM over a CASE expression per column, over a LEFT JOIN that keeps inactive subscribers.
- A cell whose week has not finished must be NULL, not zero; compare the cohort's week with the last finished week.
- State the denominator, because cohort size, served subscribers, and the previous week's actives give different numbers for the same cell.
A cohort table follows customers across weeks. A related question follows them through the steps of one process, such as asking for an insurance quote and then buying the policy. The next lesson, Funnel Queries, counts users at each step and computes the conversion between steps.
Practice Questions
1. A subscriber signs up on day 14. Which cohort week is that, and what would FLOOR(day_no / 7) do with days 7 and 14?
Day 14 is in cohort week 2. FLOOR((14 - 1) / 7) + 1 is FLOOR(1.857) + 1 = 2, the same week as days 8 to 13. FLOOR(day_no / 7) gives 1 for day 7 and 2 for day 14, so day 7 joins days 8 to 13 and day 14 joins days 15 to 20. Every week would run from a multiple of 7 to the day before the next one.
2. A cohort has 3 subscribers. In its week 2, two subscribers are active, and one of them received two boxes that week. What is week-2 retention, and what would counting delivery rows report?
<details> <summary>Show answer</summary>Retention is 2 / 3, or 67 percent. Counting delivery rows gives 3 of 3, or 100 percent, because one subscriber's two boxes count twice. Reducing deliveries to one row per subscriber and week with DISTINCT, or counting DISTINCT subscriber_id, prevents this.
3. The latest delivery in the data is on day 40. Which week is the last finished week, and which cohorts can report week-2 retention?
<details> <summary>Show answer</summary>Week 5 is the last finished week, so cohorts 1 to 3 can report week 2. FLOOR(40 / 7) is 5, because week 6 runs from day 36 to day 42 and is still in progress. A cohort's week 2 is calendar week cohort_week + 2, which must be at most 5, so the cohort week must be 3 or less.
4. Why does the cohort table start from subscribers with a LEFT JOIN, instead of starting from deliveries?
Because subscribers with no deliveries must still count in the cohort size. Subscriber 116 signed up but never received a box. Starting from deliveries gives cohort 2 a size of 3 and reports week 1 as 33 percent instead of 25 percent. The LEFT JOIN keeps 116 in the cohort while adding nothing to any week column.
5. A dashboard shows week-3 retention of 150 percent for one cohort. Is the query wrong?
<details> <summary>Show answer</summary>Not necessarily: the denominator is probably the previous week's active subscribers. If 2 subscribers were active in week 2 and 3 in week 3, because one skipped week 2 and returned, that ratio is 3 / 2. A share of the cohort can never exceed 100 percent, so the label on the dashboard should say which denominator it uses.
</details>Reading Progress
0%
On This Page
The Tables
Cohorts From Day Numbers
Week-N Retention
The Cohort Table
Cohorts Too Young to Measure
Choosing the Denominator
Day-N Retention
Practice: Build a Two-Week Cohort Table
Using This in an Interview
Key Takeaways
Practice Questions