Two people joined in the same week. One came back the following week. Both appeared the week after that. A weekly active-user total hides this story; a cohort table makes it visible.

We will group people by signup week, count who returns at each age, and build a small retention heatmap. The blank cells are as important as the colored ones.

Explore the idea

A blank square is not zero retention

Select a cohort cell to inspect the people and observation period behind it.

50%

1 returning person ÷ 2 signups

Ada returns; Ben does not

The denominator remains both people who joined in the January 5 cohort, even though only Ada appears during January 12–18.

Give every person a starting point

This is the same fictional site used in the sessions and funnel lessons. All required data is included here.

visitors5 rows

Meet the fictional visitors

Five stable user IDs; signup dates also anchor the retention lesson.

idnamesigned_up
1Ada2026-01-05
2Ben2026-01-06
3Cy2026-01-12
4Dee2026-01-13
5Eli2026-01-19

events19 rows

A tiny website event log

All 19 synthetic events are included in the CSV and setup. Times use one shared UTC clock.

iduser_idhappened_atevent
112026-01-05 09:00:00view
212026-01-05 09:05:00run
312026-01-05 09:06:00run
412026-01-05 09:15:00enquiry
512026-01-05 09:45:00view
612026-01-05 10:20:00view
722026-01-06 10:00:00run
822026-01-06 10:05:00view

A cohort begins on Monday. “Active” means at least one recorded event of any type in that calendar week. Our observation cutoff is January 26 at 00:00 UTC, exclusive: the week beginning January 19 is complete; January 26 and later are not observed.

This is calendar-week retention, not rolling seven-day retention. A Sunday signup and a Monday signup can have very different elapsed times at their first week boundary. Choose the definition that fits the decision.

Collapse repeat events before counting people

The setup creates cohort_members from signup dates and active_weeks from distinct user-week pairs. Inspect their relationship:

Assign each signup to its Monday cohort

SELECT v.name, c.cohort_week, a.active_week,
  date_diff('week', c.cohort_week, a.active_week) AS week_number
FROM cohort_members c JOIN visitors v ON v.id = c.user_id
JOIN active_weeks a USING(user_id)
ORDER BY c.cohort_week, v.name, a.active_week;
Expected result · sample data8 rows
name cohort_week active_week week_number
Ada 2026-01-05 2026-01-05 0
Ada 2026-01-05 2026-01-12 1
Ada 2026-01-05 2026-01-19 2
Ben 2026-01-05 2026-01-05 0
Ben 2026-01-05 2026-01-19 2
Cy 2026-01-12 2026-01-12 0
Dee 2026-01-12 2026-01-12 0
Eli 2026-01-19 2026-01-19 0

Scroll for more columns →

Ada’s many events in signup week contribute one active user-week. Her January 14 visit is week 1; her January 20 visit is week 2. Ben skips week 1 and returns in week 2.

date_trunc('week', ...) anchors the dates to Mondays. Calculating the week difference between these aligned boundaries produces the age of each cohort. Activity before a user’s signup cohort is excluded from the retention calculation.

Build the cells that have no activity too

If we group only observed events, a week with no returns disappears. Instead, cross each cohort with the week numbers we want, then left join the observed counts:

Build the cohort grid, leaving future weeks unknown

WITH sizes AS (
 SELECT cohort_week, count(*) AS cohort_size FROM cohort_members GROUP BY cohort_week
), counts AS (
 SELECT c.cohort_week, date_diff('week', c.cohort_week, a.active_week) AS week_number,
   count(DISTINCT c.user_id) AS active_users
 FROM cohort_members c JOIN active_weeks a USING(user_id)
 WHERE a.active_week >= c.cohort_week
 GROUP BY c.cohort_week, week_number
)
SELECT s.cohort_week, w.week_number, s.cohort_size,
 CASE WHEN s.cohort_week + w.week_number * INTERVAL 1 WEEK < DATE '2026-01-26'
   THEN coalesce(c.active_users, 0) END AS active_users,
 CASE WHEN s.cohort_week + w.week_number * INTERVAL 1 WEEK < DATE '2026-01-26'
   THEN round(100.0 * coalesce(c.active_users, 0) / s.cohort_size, 1) END AS retention_pct
FROM sizes s CROSS JOIN range(3) w(week_number)
LEFT JOIN counts c ON c.cohort_week = s.cohort_week AND c.week_number = w.week_number
ORDER BY s.cohort_week, w.week_number;
Expected result · sample data9 rows
cohort_week week_number cohort_size active_users retention_pct
2026-01-05 0 2 2 100
2026-01-05 1 2 1 50
2026-01-05 2 2 2 100
2026-01-12 0 2 2 100
2026-01-12 1 2 0 0
2026-01-12 2 2 NULL NULL
2026-01-19 0 1 1 100
2026-01-19 1 1 NULL NULL
2026-01-19 2 1 NULL NULL

Scroll for more columns →

The denominator comes from all signups in the cohort, not the people who happen to return. The January 5 cohort has two members at every age. One returning person in week 1 therefore means 50%, even though that week’s activity table contains only Ada.

coalesce supplies zero for an observed period with no activity. The outer CASE leaves unobserved future periods NULL. Here the cutoff is a Monday, so every included week is complete. For an arbitrary midweek cutoff, require the period’s end to be at or before the cutoff before treating it as a completed week.

The fixture assumes event collection is complete through the cutoff. A missing export is not evidence of zero retention.

Turn the grid into a report

Turn the percentages into a cohort report

PIVOT retention ON week_number IN (0, 1, 2)
USING max(retention_pct) GROUP BY cohort_week, cohort_size
ORDER BY cohort_week;
Expected result · sample data3 rows
cohort_week cohort_size 0 1 2
2026-01-05 2 100 50 100
2026-01-12 2 100 0 NULL
2026-01-19 1 100 NULL NULL

Scroll for more columns →

The January 5 cohort reads 100%, 50%, 100%. Retention can rise again because this metric asks who was active in each particular week. It does not require uninterrupted weekly activity.

The January 12 cohort has 0% in week 1: that week was observed, and neither member returned. Its week 2 cell is NULL because it lies beyond the observation cutoff. Displaying both as zero would make newer cohorts look artificially worse.

Your turn · check your answer

Inspect the people behind one retention cell

Return name for members of the January 5 cohort who were active in week 1 (January 12–18). Return each name once.

See the expected result
Your target result1 row
name
Ada
Sample data is loaded for you.

Preparing the editor…

Need a hint?

Filter both the signup cohort and the activity week. Joining only on cohort membership includes people who did not return that week.

Show a solution
SELECT DISTINCT v.name FROM cohort_members c JOIN visitors v ON v.id = c.user_id JOIN active_weeks a USING(user_id) WHERE c.cohort_week = DATE '2026-01-05' AND a.active_week = DATE '2026-01-12' ORDER BY v.name;

Make the cell inspectable

This tiny dataset is useful because we can name every person behind a percentage. Keep the same ability in a real analysis: a denominator, an observation window, and a query that explains membership.

Week 0 is not automatically 100% under this definition. It is 100% here because every signup also has a recorded event in that week. A signup with no event would remain in the denominator without contributing to activity. Decide whether that is the behavior your product team wants before interpreting the heatmap.