SQL (PostgreSQL, MySQL, SQLite) Module 19 – SQL for data analysis
Cohort analysis in SQL: retention and funnels
Group users into sign-up cohorts, build retention matrices with the cohort size as denominator, and count funnel steps taken in order within a window.
What you will learn
- Group customers into cohorts by the month of their first order and count them per month since
- Build a retention matrix that divides by the cohort's size and keeps every cohort month
- Measure day-1, day-7 and day-30 retention without counting users too new to have reached the day
- Count the steps of a funnel in order and within a time window
- Recognise survivorship and denominator mistakes in retention figures
Before you start
What you’ll learn · Before you start
What you will learn
- Group customers into cohorts by the month of their first order and count them per month since
- Build a retention matrix that divides by the cohort's size and keeps every cohort month
- Measure day-1, day-7 and day-30 retention without counting users too new to have reached the day
- Count the steps of a funnel in order and within a time window
- Recognise survivorship and denominator mistakes in retention figures
Before you start
On this page
A cohort is a group of users who started in the same period, usually the month of their sign-up or first order.
Retention is the share of a cohort that is active again in a later period: if 40 customers ordered for the first time
in April and 14 of them ordered again in May, the April cohort’s month-1 retention is 35 %. A funnel counts how many
users reach each step of a journey in order, such as signing up, a first order and a second order. Cohorts, retention
and funnels are all GROUP BY queries over one row per user and period, or per user and step, and in all of them the
denominator decides whether a figure is honest: the cohort’s size, only the users old enough to be measured, and only
the users who reached the step before.
A cohort table, worked on ten customers
Ten customers of an online shop placed 21 orders between April and July. Each customer’s cohort is the month of the
first order, month_number counts the months since then, and the share divides the active customers by the cohort’s
size. The query runs on SQLite 3.49.1 in your browser:
-- Ten customers of an online shop and the days on which they ordered.
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
ordered_on TEXT NOT NULL -- 'YYYY-MM-DD' on India's calendar
);
INSERT INTO orders (customer_id, ordered_on) VALUES
(1, '2026-04-03'), (1, '2026-05-10'), (1, '2026-06-15'), (1, '2026-07-02'),
(2, '2026-04-07'), (2, '2026-04-20'), (2, '2026-06-01'),
(3, '2026-04-15'),
(4, '2026-04-28'), (4, '2026-05-02'),
(5, '2026-05-05'), (5, '2026-05-25'), (5, '2026-07-19'),
(6, '2026-05-12'), (6, '2026-06-12'),
(7, '2026-05-30'),
(8, '2026-06-02'), (8, '2026-07-08'),
(9, '2026-06-18'),
(10, '2026-06-25'), (10, '2026-07-30');
-- A customer's cohort is the month of the first order; month_number counts the months since then.
WITH firsts AS (
SELECT customer_id, min(ordered_on) AS first_day
FROM orders
GROUP BY customer_id
),
activity AS ( -- one row per customer and month in which they ordered
SELECT DISTINCT o.customer_id,
strftime('%Y-%m', f.first_day) AS cohort,
(cast(strftime('%Y', o.ordered_on) AS INTEGER) - cast(strftime('%Y', f.first_day) AS INTEGER)) * 12
+ cast(strftime('%m', o.ordered_on) AS INTEGER)
- cast(strftime('%m', f.first_day) AS INTEGER) AS month_number
FROM orders AS o
JOIN firsts AS f ON f.customer_id = o.customer_id
),
sizes AS ( -- every customer is active in month 0, the month of the first order
SELECT cohort, count(*) AS cohort_size FROM activity WHERE month_number = 0 GROUP BY cohort
)
SELECT a.cohort, a.month_number, count(*) AS customers, s.cohort_size,
round(100.0 * count(*) / s.cohort_size, 1) AS retention_pct
FROM activity AS a
JOIN sizes AS s ON s.cohort = a.cohort
GROUP BY a.cohort, a.month_number
ORDER BY a.cohort, a.month_number; Output
┌─────────┬──────────────┬───────────┬─────────────┬───────────────┐ │ cohort │ month_number │ customers │ cohort_size │ retention_pct │ ├─────────┼──────────────┼───────────┼─────────────┼───────────────┤ │ 2026-04 │ 0 │ 4 │ 4 │ 100.0 │ │ 2026-04 │ 1 │ 2 │ 4 │ 50.0 │ │ 2026-04 │ 2 │ 2 │ 4 │ 50.0 │ │ 2026-04 │ 3 │ 1 │ 4 │ 25.0 │ │ 2026-05 │ 0 │ 3 │ 3 │ 100.0 │ │ 2026-05 │ 1 │ 1 │ 3 │ 33.3 │ │ 2026-05 │ 2 │ 1 │ 3 │ 33.3 │ │ 2026-06 │ 0 │ 3 │ 3 │ 100.0 │ │ 2026-06 │ 1 │ 2 │ 3 │ 66.7 │ └─────────┴──────────────┴───────────┴─────────────┴───────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < cohort_by_hand.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
Every cohort starts at 100 %, because month 0 is the month in which each of its customers first ordered. Of the four April customers, two ordered in May and two in June, and one, customer 1, ordered in all four months: 50, 50 and 25 %. Later cohorts have fewer months behind them, so the table forms a triangle. Retention does not have to fall every month either: the April cohort holds at 50 % in month 2 because customer 2 came back after a month away.
From orders to a retention triangle
Text description of the diagram
The diagram follows the ten customers of the first example from top to bottom.
1. The orders table has one row per order: 21 orders of ten customers. 2. Three steps turn it into counts: each customer's cohort is the month of their first order, each order becomes a month number counted from that month, and distinct customers are counted per cohort and month number. 3. Each count is divided by the size of its cohort. The April cohort of 4 customers keeps 100, 50, 50 and 25 % in months 0 to 3; the May cohort of 3 keeps 100, 33.3 and 33.3 %; the June cohort of 3 keeps 100 and 66.7 %.
Later cohorts have fewer months behind them, so the rows get shorter and the table has the shape of a triangle.
Three details keep the numbers right. SELECT DISTINCT keeps one row per customer and month, so customer 2’s two
April orders count once. The month number comes from years and months, never from days, so an order on the last day
of a month and one on the first day of the next are one month apart. And the cohort’s size comes from month 0 of the
cohort itself, never from the customers who happen to be active in a later month.
Around such a table sit five more ideas, each in one sentence:
- Day-N retention counts users active exactly N days after their own sign-up, such as day 1, day 7 or day 30.
- Rolling retention counts users active on day N or on any later day.
- Immature cohorts have not yet lived N days, so they leave day N’s denominator.
- A funnel counts users who reached each step after the step before, within a time window.
- Survivorship bias inflates retention when deleted or lapsed accounts drop out of a cohort.
Day 1, day 7 and day 30, with immature cohorts left out Pro: opens with a Pro, Premium or Ultimate pass
In this part:
- A cohort matrix that keeps every cohort and every month
- Funnels with steps in order, inside a time window
- Survivorship and denominator mistakes, reproduced
- How the engines differ
- Your turn
- A monthly cohort retention table with every cohort
- Check yourself
- Interview questions
- What is a cohort, and why is a cohort's retention divided by the cohort's size?
- A dashboard's day-30 retention fell from 38 % to 22 % over two months, while the product team changed nothing. Sign-ups doubled in the same period after a campaign. What do you check first, and how do you fix the figure?
- Build a funnel for 10 million users: signed up, placed a first order within 30 days of signing up, placed a second order within 30 days of the first. Some users ordered as guests before signing up. Write the query plan and explain every counting rule you apply.
- A monthly cohort matrix has no row at all for one month, and one of its cells shows 112 % retention. Explain both problems and rewrite the query so that neither can happen.
- Key takeaways
4 runnable examples (SQL) · 1 exercise with sample tests · 3 interview answers · about 21 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
References
- PostgreSQL documentation: Window Functions (The PostgreSQL Global Development Group)
- PostgreSQL documentation: Value Expressions (aggregate expressions, FILTER and window function calls) (The PostgreSQL Global Development Group)
- PostgreSQL documentation: Date/Time Functions and Operators (extract, age) (The PostgreSQL Global Development Group)
- MySQL 8.4 Reference Manual: Window Function Restrictions (Oracle Corporation)
- MySQL 8.4 Reference Manual: Date and Time Functions (PERIOD_DIFF, TIMESTAMPDIFF, DATEDIFF) (Oracle Corporation)
- SQLite: Window Functions (SQLite)
- SQLite: Built-in Aggregate Functions (DISTINCT and FILTER) (SQLite)
- SQLite: Date And Time Functions (SQLite)
- SQLite: The WITH Clause (recursive common table expressions) (SQLite)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress