Recipe Tableau and SQL
How do I build a monthly cohort retention table?
Give every customer a cohort, the month of their first purchase, with { FIXED [customer_id] : MIN([order_date]) }. Then count distinct customers active at each month after that and divide by the cohort's size. At this bookshop the biggest cohort, a promo month of 78 new customers, kept only 10.3% of them three months later, against 37.7% for every other month.
The data
Lantern Books loyalty-card purchases, 2025
An indie bookshop's loyalty card, January to December 2025. In March a 40%-off spring sale signed up more than twice the usual number of new members.
customer_id | order_date | amount |
|---|---|---|
| LB-0012 | 2025-01-01 | 19.98 |
| LB-0016 | 2025-01-01 | 14.99 |
| LB-0020 | 2025-01-01 | 25 |
| LB-0032 | 2025-01-01 | 45 |
Step 1
Give every customer a cohort
Each bar counts the customers whose first purchase fell in that month. March towers over the rest: that's the spring sale. In Tableau, save the formula as a field called Cohort month and drag it to Columns, with COUNTD([customer_id]) on Rows.
Tableau
DATETRUNC('month', { FIXED [customer_id] : MIN([order_date]) })SQL
WITH f AS (
SELECT
customer_id,
date_trunc('month', MIN(order_date)) AS cohort_month
FROM input
GROUP BY customer_id
)
SELECT cohort_month, COUNT(*) AS customers
FROM f
GROUP BY cohort_month
ORDER BY cohort_monthRows
| Cohort month | Customers |
|---|---|
| Jan 2025 | 34 |
| Feb 2025 | 30 |
| Mar 2025 | 78 |
| Apr 2025 | 32 |
| May 2025 | 29 |
| Jun 2025 | 27 |
| Jul 2025 | 31 |
| Aug 2025 | 33 |
| Sep 2025 | 36 |
Common mistake
MIN of the date without FIXED
Without FIXED, MIN([order_date]) is computed inside each cell of the view. In a view by month it returns each month's first purchase, so every customer gets a new cohort every month they shop, and the cohorts add up to far more people than exist.
Tableau
DATETRUNC('month', MIN([order_date]))SQL
SELECT cohort_month, COUNT(*) AS customers
FROM (
SELECT
customer_id,
date_trunc('month', MIN(order_date)) AS cohort_month
FROM input
GROUP BY customer_id, date_trunc('month', order_date)
)
GROUP BY cohort_month
ORDER BY cohort_monthTableau
DATETRUNC('month', { FIXED [customer_id] : MIN([order_date]) })SQL
SELECT cohort_month, COUNT(*) AS customers
FROM (
SELECT
customer_id,
date_trunc('month', MIN(order_date)) AS cohort_month
FROM input
GROUP BY customer_id
)
GROUP BY cohort_month
ORDER BY cohort_monthStep 2
Count who came back, month by month
Month 0 is the first purchase, month 1 the next calendar month, and so on. A customer who buys three books in one month counts once: that's why it's COUNTD of customers, never a count of rows. The March row starts big and fades fastest.
Tableau
DATEDIFF('month', [Cohort month], DATETRUNC('month', [order_date]))
// with COUNTD([customer_id])SQL
WITH m AS (
SELECT
DISTINCT customer_id,
date_trunc('month', order_date) AS month
FROM input
), f AS (
SELECT customer_id, MIN(month) AS cohort_month
FROM m
GROUP BY customer_id
)
SELECT
f.cohort_month,
date_diff('month', f.cohort_month, m.month) AS month_number,
COUNT(*) AS active_customers
FROM m JOIN f USING (customer_id)
GROUP BY ALL
ORDER BY cohort_month, month_numberRows
| Month | Month number | Active customers |
|---|---|---|
| Jan 2025 | 0 | 34 |
| Jan 2025 | 1 | 16 |
| Jan 2025 | 2 | 15 |
| Jan 2025 | 3 | 13 |
| Jan 2025 | 4 | 12 |
| Jan 2025 | 5 | 5 |
| Jan 2025 | 6 | 13 |
| Jan 2025 | 7 | 17 |
| Jan 2025 | 8 | 11 |
| Jan 2025 | 9 | 7 |
| Jan 2025 | 10 | 9 |
| Jan 2025 | 11 | 16 |
| Feb 2025 | 0 | 30 |
| Feb 2025 | 1 | 15 |
| Feb 2025 | 2 | 10 |
| Feb 2025 | 3 | 9 |
| Feb 2025 | 4 | 8 |
| Feb 2025 | 5 | 9 |
| Feb 2025 | 6 | 9 |
| Feb 2025 | 7 | 10 |
| Feb 2025 | 8 | 5 |
| Feb 2025 | 9 | 9 |
| Feb 2025 | 10 | 9 |
| Mar 2025 | 0 | 78 |
| Mar 2025 | 1 | 9 |
| Mar 2025 | 2 | 9 |
| Mar 2025 | 3 | 8 |
| Mar 2025 | 4 | 5 |
| Mar 2025 | 5 | 5 |
| Mar 2025 | 6 | 1 |
| Mar 2025 | 7 | 6 |
| Mar 2025 | 8 | 10 |
| Mar 2025 | 9 | 4 |
| Apr 2025 | 0 | 32 |
| Apr 2025 | 1 | 20 |
| Apr 2025 | 2 | 16 |
| Apr 2025 | 3 | 10 |
| Apr 2025 | 4 | 11 |
| Apr 2025 | 5 | 16 |
| Apr 2025 | 6 | 14 |
| Apr 2025 | 7 | 10 |
| Apr 2025 | 8 | 9 |
| May 2025 | 0 | 29 |
| May 2025 | 1 | 18 |
| May 2025 | 2 | 12 |
| May 2025 | 3 | 15 |
| May 2025 | 4 | 17 |
| May 2025 | 5 | 9 |
| May 2025 | 6 | 12 |
| May 2025 | 7 | 10 |
First 50 of 72 rows.
Step 3
Divide by the cohort's size
The second FIXED gives each cohort its starting size, so every cell is a share of the people who started. The grid runs only to December, the last month in the file: the empty corner is the future, not a zero. In SQL the generate_series scaffold keeps any observed month with no returns as a real 0.
Tableau
COUNTD([customer_id]) / MIN({ FIXED [Cohort month] : COUNTD([customer_id]) })SQL
WITH m AS (
SELECT
DISTINCT customer_id,
date_trunc('month', order_date) AS month
FROM input
), f AS (
SELECT customer_id, MIN(month) AS cohort_month
FROM m
GROUP BY customer_id
), s AS (
SELECT cohort_month, COUNT(*) AS customers
FROM f
GROUP BY cohort_month
), a AS (
SELECT
f.cohort_month,
date_diff('month', f.cohort_month, m.month) AS month_number,
COUNT(*) AS active
FROM m JOIN f USING (customer_id)
GROUP BY ALL
), g AS (
SELECT s.cohort_month, s.customers, n AS month_number
FROM s, generate_series(0, date_diff('month', s.cohort_month, (
SELECT MAX(month)
FROM m
))) AS t(n)
)
SELECT
g.cohort_month,
g.month_number,
g.customers,
COALESCE(a.active, 0) AS active,
COALESCE(a.active, 0) / g.customers AS retention
FROM g LEFT JOIN a USING (cohort_month, month_number)
ORDER BY cohort_month, month_numberRows
| Month | Month number | Customers | Active | Retention |
|---|---|---|---|---|
| Jan 2025 | 0 | 34 | 34 | 100% |
| Jan 2025 | 1 | 34 | 16 | 47.1% |
| Jan 2025 | 2 | 34 | 15 | 44.1% |
| Jan 2025 | 3 | 34 | 13 | 38.2% |
| Jan 2025 | 4 | 34 | 12 | 35.3% |
| Jan 2025 | 5 | 34 | 5 | 14.7% |
| Jan 2025 | 6 | 34 | 13 | 38.2% |
| Jan 2025 | 7 | 34 | 17 | 50% |
| Jan 2025 | 8 | 34 | 11 | 32.4% |
| Jan 2025 | 9 | 34 | 7 | 20.6% |
| Jan 2025 | 10 | 34 | 9 | 26.5% |
| Jan 2025 | 11 | 34 | 16 | 47.1% |
| Feb 2025 | 0 | 30 | 30 | 100% |
| Feb 2025 | 1 | 30 | 15 | 50% |
| Feb 2025 | 2 | 30 | 10 | 33.3% |
| Feb 2025 | 3 | 30 | 9 | 30% |
| Feb 2025 | 4 | 30 | 8 | 26.7% |
| Feb 2025 | 5 | 30 | 9 | 30% |
| Feb 2025 | 6 | 30 | 9 | 30% |
| Feb 2025 | 7 | 30 | 10 | 33.3% |
| Feb 2025 | 8 | 30 | 5 | 16.7% |
| Feb 2025 | 9 | 30 | 9 | 30% |
| Feb 2025 | 10 | 30 | 9 | 30% |
| Mar 2025 | 0 | 78 | 78 | 100% |
| Mar 2025 | 1 | 78 | 9 | 11.5% |
| Mar 2025 | 2 | 78 | 9 | 11.5% |
| Mar 2025 | 3 | 78 | 8 | 10.3% |
| Mar 2025 | 4 | 78 | 5 | 6.4% |
| Mar 2025 | 5 | 78 | 5 | 6.4% |
| Mar 2025 | 6 | 78 | 1 | 1.3% |
| Mar 2025 | 7 | 78 | 6 | 7.7% |
| Mar 2025 | 8 | 78 | 10 | 12.8% |
| Mar 2025 | 9 | 78 | 4 | 5.1% |
| Apr 2025 | 0 | 32 | 32 | 100% |
| Apr 2025 | 1 | 32 | 20 | 62.5% |
| Apr 2025 | 2 | 32 | 16 | 50% |
| Apr 2025 | 3 | 32 | 10 | 31.3% |
| Apr 2025 | 4 | 32 | 11 | 34.4% |
| Apr 2025 | 5 | 32 | 16 | 50% |
| Apr 2025 | 6 | 32 | 14 | 43.8% |
| Apr 2025 | 7 | 32 | 10 | 31.3% |
| Apr 2025 | 8 | 32 | 9 | 28.1% |
| May 2025 | 0 | 29 | 29 | 100% |
| May 2025 | 1 | 29 | 18 | 62.1% |
| May 2025 | 2 | 29 | 12 | 41.4% |
| May 2025 | 3 | 29 | 15 | 51.7% |
| May 2025 | 4 | 29 | 17 | 58.6% |
| May 2025 | 5 | 29 | 9 | 31% |
| May 2025 | 6 | 29 | 12 | 41.4% |
| May 2025 | 7 | 29 | 10 | 34.5% |
First 50 of 72 rows.
Did the spring sale bring in loyal customers?
No. March brought in 78 new members, more than twice February's 30. Three months on, March had 8 still buying and February had 9: the biggest cohort of the year kept fewer regulars than one of the smallest. A discount that big mostly attracts people who came for the discount.
Step 4
Compare cohorts at the same age
One column of the heatmap, as bars. Only cohorts old enough to have reached that age appear; slide the age up and the newest months drop out. Whatever the age, March sits at the bottom.
Tableau
// filter [Month number] = {knob.age}
COUNTD([customer_id]) / MIN({ FIXED [Cohort month] : COUNTD([customer_id]) })SQL
WITH m AS (
SELECT
DISTINCT customer_id,
date_trunc('month', order_date) AS month
FROM input
), f AS (
SELECT customer_id, MIN(month) AS cohort_month
FROM m
GROUP BY customer_id
), s AS (
SELECT cohort_month, COUNT(*) AS customers
FROM f
GROUP BY cohort_month
), a AS (
SELECT f.cohort_month, COUNT(*) AS active
FROM m JOIN f USING (customer_id)
WHERE date_diff('month', f.cohort_month, m.month) = 3
GROUP BY f.cohort_month
)
SELECT
s.cohort_month,
s.customers,
COALESCE(a.active, 0) AS active,
COALESCE(a.active, 0) / s.customers AS retention
FROM s LEFT JOIN a USING (cohort_month)
WHERE date_diff('month', s.cohort_month, (
SELECT MAX(month)
FROM m
)) >= 3
ORDER BY s.cohort_monthRows
| Month | Customers | Active | Retention |
|---|---|---|---|
| Jan 2025 | 34 | 13 | 38.2% |
| Feb 2025 | 30 | 9 | 30% |
| Mar 2025 | 78 | 8 | 10.3% |
| Apr 2025 | 32 | 10 | 31.3% |
| May 2025 | 29 | 15 | 51.7% |
| Jun 2025 | 27 | 13 | 48.1% |
| Jul 2025 | 31 | 13 | 41.9% |
| Aug 2025 | 33 | 9 | 27.3% |
| Sep 2025 | 36 | 13 | 36.1% |