A practice prompt we wrote. No company or candidate report names it, so it carries no company tag.
How to answer
Three definitions decide this answer before any SQL does: what counts as a return, when a customer joined, and what you divide by. Settle them out loud before you type.
- Define “return”, because it has two meanings. In retail, a “return rate” can mean products sent back for a refund, and a question about cohorts can also mean customers coming back to buy again. Ask which. If they mean product returns, the numerator is returned orders, the denominator is the orders shipped to that cohort in that month, and a return belongs to the original order’s month, not the date it came back. If they mean repeat purchase, it’s a retention question, and the steps below build it. Either way, say which one you’re answering and why.
- Define the cohort from all history. A customer’s cohort is the month of their first qualifying order, found before any date filter. Say which orders qualify (not canceled, not a test account) and use the same rule for the cohort and for the return, or a customer can return before they joined. A reporting-window filter goes on the output rows, after cohorts are assigned; applied before
min(), it moves long-standing customers into new cohorts. - Fix the grain. Collapse orders to one row per customer per month first. A customer with four orders in a month counts once.
- Name the numerator and the denominator out loud. Numerator: cohort members with a qualifying order in month
kafter their cohort month. Denominator: everyone in the cohort, whether or not they came back. Month zero is the whole cohort by construction, so start at month one. Say whether monthkis a calendar month or a window of days since the first order: with calendar months, a customer who buys on the last evening of January and again the next morning counts as returning in month one. - Handle months that haven’t happened. A cohort from last month has no month three yet. Show nothing there, not zero, and leave out the current partial month.
- Check it by hand. Pick one cohort, count its customers and returners with a separate query, and compare.
Cohorts, numerators and denominators, retention curves and fan-out are on the syllabus of SQL and data for FDEs, in Pro.
Follow-ups
What the interviewer may ask next, once your first answer is on the table.
- The business asks for “ever returned by month three” instead. What changes in the query?
- Finance wants cohorts by the customer’s local month, and your timestamps are UTC. Where does that go?
- Refunds arrive weeks after the order. Does a refunded first order still put a customer in that cohort?
Where answers go wrong
- Filters the orders to the reporting window before finding each customer’s first purchase, so long-standing customers who bought again land in a new cohort and the early months look like a wave of new buyers.
- Divides by the cohort members active that month instead of the cohort size. That is the numerator itself, so every rate comes out as one and the curve says nothing.
Answer this in two minutes
Write the answer you would say out loud. The clock starts with your first word.
Compare with the model answer
Model answer
“I’ll read ‘return’ as a repeat purchase, because the question groups customers by their first purchase and asks by month, which is the shape of a retention curve, and I’d confirm that before typing. If you meant products sent back, the cohorts stay the same, but I’d divide returned orders by the orders shipped to the cohort that month, and date each return by its original order.”
“I’ll treat canceled orders and test accounts as if they never happened, for both the cohort and the return. First I collapse to one row per customer per month, then assign each customer the month of their first order across all history.”
WITH purchases AS ( -- one row per customer per month
SELECT DISTINCT o.customer_id,
date_trunc('month', o.ordered_at)::date AS order_month
FROM orders o
JOIN customers cu USING (customer_id)
WHERE o.status <> 'canceled'
AND NOT cu.is_test
),
cohorts AS ( -- no date filter here, on purpose
SELECT customer_id, min(order_month) AS cohort_month
FROM purchases
GROUP BY customer_id
),
cohort_size AS (
SELECT cohort_month, count(*) AS customers
FROM cohorts
GROUP BY cohort_month
),
returns AS (
SELECT c.cohort_month,
((extract(year FROM p.order_month) - extract(year FROM c.cohort_month)) * 12
+ extract(month FROM p.order_month) - extract(month FROM c.cohort_month))::int
AS month_offset,
count(*) AS returners
FROM purchases p
JOIN cohorts c USING (customer_id)
WHERE p.order_month > c.cohort_month
GROUP BY 1, 2
),
grid AS ( -- only months that are complete
SELECT s.cohort_month, s.customers, k AS month_offset
FROM cohort_size s
CROSS JOIN generate_series(1, 12) AS k
WHERE s.cohort_month + make_interval(months => k) < date_trunc('month', current_date)
)
SELECT g.cohort_month,
g.month_offset,
g.customers AS cohort_size,
coalesce(r.returners, 0) AS returners,
round(coalesce(r.returners, 0)::numeric / g.customers, 3) AS return_rate
FROM grid g
LEFT JOIN returns r USING (cohort_month, month_offset)
ORDER BY g.cohort_month, g.month_offset;
“A few things I’d point at while I run it.”
“The grid is why a month where nobody came back shows 0 instead of vanishing, and why a month that hasn’t finished shows no row at all. Without it the chart silently drops the bad months and the curve looks better than it is. The grid stops at month 12; I’d widen it if the chart needs a longer tail.”
“Pivoted for the chart, with cohorts down the side and months since first purchase across the top, the last three cohorts of a run partway through September look like this. Read down a column to compare cohorts at the same age. The blank triangle is months that haven’t finished, not zero, and the August cohort has no row yet, because its month one is September, which is still running.”
| Cohort | Month one | Month two | Month three |
|---|---|---|---|
2026-05-01 | 0.221 | 0.143 | 0.118 |
2026-06-01 | 0.214 | 0.137 | |
2026-07-01 | 0.198 |
“The denominator is the full cohort. Because purchases is already one row per customer per month, count(*) in returns counts people, not orders.”
“These are calendar-month offsets, so a second order the next morning, across a month end, counts as a month-one return. If the business means time since first purchase, I’d keep each customer’s first order timestamp and bucket the days elapsed into 30-day windows instead.”
“date_trunc on a timestamptz uses the session time zone, so if the session runs in UTC, an order placed late in the evening on the last day of a month in New York lands in the next month. If the business reports by a local calendar, I’d convert first with ordered_at AT TIME ZONE 'America/New_York'; the PostgreSQL documentation for date_trunc describes that behavior.”
“To check it, I take one cohort and count by hand: its customers from cohorts, then how many of them have any order in the month after. If that matches the row for month one, I trust the rest.”
“For ‘ever returned by month k’, I’d take each customer’s first month after their cohort month, then use a running count of those over the offsets. The per-month rate can go up and down; the cumulative one can never fall.”
“On refunds: if a fully refunded first order shouldn’t count, a recent cohort can still change until the refund window closes, so I’d mark the last few cohorts as provisional on the chart rather than quietly restate them later.”
“The last thing I’d ask about is identity. If the same person has two customer_ids, one from a guest checkout and one from an account, they land in two cohorts: the first customer_id looks like a customer who never came back, and the second like a new customer. I’d ask whether there’s a merged identity table, and assign cohorts on that ID instead.”