EduFlow
SQL for Analysts
0%

Section 3 · Analytical SQL

Funnels, Cohorts and Retention

20 min3 min readSQL exercise

Lesson at a glance

Build a conversion funnel from session events with conditional aggregation, then group customers into monthly cohorts by first order and measure how many come back in later months.

  1. 1A funnel from session events
  2. 2Cohorts: grouping customers by when they started
  3. 3Unknown is not zero

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Two requests sit at the top of the queue. Huda: “Our paid search budget is up for review. Which traffic sources actually turn visits into orders, and where do people drop off?” Rania: “We acquired a lot of customers in Black Friday months. Do they come back as often as everyone else?” The first is a funnel, the second a cohort retention analysis. Both are built from things you already know: conditional aggregation, dates and CTEs.

A funnel from session events

Each row of web_sessions is one visit, with flags for the steps it reached: added_to_cart and purchased (0 or 1). Summing a 0/1 flag counts the sessions that reached the step, so a funnel is one GROUP BY:

SQL
SELECT
  source,
  COUNT(*)            AS sessions,
  SUM(added_to_cart)  AS carts,
  SUM(purchased)      AS purchases,
  ROUND(100.0 * SUM(added_to_cart) / COUNT(*), 1)                  AS cart_rate,
  ROUND(100.0 * SUM(purchased) / NULLIF(SUM(added_to_cart), 0), 1) AS checkout_rate,
  ROUND(100.0 * SUM(purchased) / COUNT(*), 1)                      AS conversion
FROM web_sessions
WHERE started_at >= '2025-01-01'
GROUP BY source
ORDER BY conversion DESC;

Email converts best overall, 10.9% of sessions, and referral worst at 7.1%. The step rates tell the more useful story. Referral visitors rarely add to cart (13.3%), but those who do check out more often than anyone (53.6%). Email is the reverse. Those are different problems with different fixes: referral needs better landing pages, email needs a smoother checkout.

That’s why a funnel reports step rates, each step divided by the previous one, not only end-to-end conversion. Make each denominator explicit in the column name; “conversion rate” without a denominator is the most argued-about number in any marketing meeting. NULLIF guards the division for a source with no carts.

Swap source for device and a sharper finding appears: desktop sessions that reach the cart check out 56% of the time, mobile sessions only 37%. Mobile brings the most traffic, so that gap is worth more than any change to the ad budget.

Cohorts: grouping customers by when they started

A cohort is a group of customers who share a starting period. Here, the month of their first non-cancelled order. Retention then asks: of the customers who started in month M, what share ordered again 1, 2, 3 months later?

Three steps, three CTEs. First, give every order a month index, year * 12 + month, so that month differences are simple subtraction even across a year boundary. Second, find each customer’s first month. Third, list each customer’s active months relative to their cohort:

SQL
WITH orders_ok AS (
  SELECT customer_id, order_date,
         CAST(strftime('%Y', order_date) AS INTEGER) * 12
           + CAST(strftime('%m', order_date) AS INTEGER) AS month_index
  FROM orders
  WHERE status <> 'cancelled'
),
firsts AS (
  -- grain: one row per customer
  SELECT customer_id,
         MIN(month_index) AS cohort_index,
         strftime('%Y-%m', MIN(order_date)) AS cohort
  FROM orders_ok
  GROUP BY customer_id
),
activity AS (
  -- grain: one row per customer per active month
  SELECT DISTINCT f.cohort, f.cohort_index, o.customer_id,
         o.month_index - f.cohort_index AS month_number
  FROM orders_ok AS o
  JOIN firsts AS f ON f.customer_id = o.customer_id
)
SELECT
  cohort,
  COUNT(*) FILTER (WHERE month_number = 0) AS customers,
  ROUND(100.0 * COUNT(*) FILTER (WHERE month_number = 1)
        / COUNT(*) FILTER (WHERE month_number = 0), 1) AS m1_pct,
  CASE WHEN MAX(cohort_index) + 2 <= 2025 * 12 + 12 THEN
    ROUND(100.0 * COUNT(*) FILTER (WHERE month_number = 2)
          / COUNT(*) FILTER (WHERE month_number = 0), 1)
  END AS m2_pct
FROM activity
WHERE cohort >= '2025-07'
GROUP BY cohort
ORDER BY cohort;

The final SELECT pivots month numbers into columns with FILTER. The DISTINCT in activity is essential: retention counts people, and a customer who orders three times in a month is one active customer.

Cohorts from September to December 2025 against months 0 to 3. September has all four months measured; October three; November two; December only month 0. The cells after the end of the data are unknown, not zero. m0 m1 m2 m3 2025-09 2025-10 2025-11 2025-12 100% 43.5% 39.1% 30.4% 100% 44.4% 27.8% not yet 100% 36.2% not yet not yet 100% not yet not yet not yet
A cohort table is a triangle: recent cohorts haven’t lived long enough to measure

Unknown is not zero

Look at the December 2025 row in the query’s output: m1_pct is 0.0. That isn’t churn. Month 1 for December’s cohort is January 2026, and the data ends on 31 December. The m2_pct column shows the fix: a CASE expression checks whether the cohort has lived through that month (cohort_index + 2 is still within December 2025) and returns NULL otherwise. A retention chart with honest blanks is a triangle; one with zeros shows a fake collapse in every recent cohort.

Back to Rania’s question. Change the filter to 2024 and the November 2024 cohort, Cartwheel’s first Black Friday crowd and the largest of that year at 35 customers, retains only 14.3% in month 1, below most 2024 cohorts. Deep-discount customers tend to come for the deal. That’s an answer she can use when planning the next promotion. The exercise asks a related question: what share of each cohort comes back at all within three months?

This closes the analytical toolkit. The final section is about trust: making sure the data and your queries deserve it.

Key takeaways

  • A funnel counts how many units reach each step and divides each step by the one before it, so you can see exactly where people drop off.
  • Always state the denominator of a rate; checkout rate (purchases per cart) and conversion rate (purchases per session) answer different questions.
  • A cohort groups customers by the period of their first event, and retention counts distinct customers active N periods later divided by the cohort size.
  • Months a recent cohort hasn’t lived through yet are unknown, not zero; show them as NULL so nobody reads them as churn.

Further reading

Check your understanding

4 questions

Question 1 of 4

Email sessions: 404 sessions, 107 add to cart, 44 purchase. Which number is the checkout rate?

Question 2 of 4

Why does the cohort query use SELECT DISTINCT cohort, customer_id, month_number before counting?

Question 3 of 4

The December 2025 cohort shows 0% retention in month 1. The data ends on 2025-12-31. What should the table show?

Question 4 of 4

To compute “months since first order” across a year boundary, why convert dates to year * 12 + month instead of subtracting strftime('%m', ...) values?

Practice

SQL exercise

Three-month repeat rate by cohort

Rania wants one number per cohort: of the customers whose first order was in a given month, what percentage ordered again in any of the next three months (month numbers 1, 2 or 3)?

Return cohort (‘YYYY-MM’), customers (cohort size) and repeat_3m_pct (rounded to 1 decimal) for the cohorts from 2025-01 to 2025-09, which have all three months observed. Sort by cohort. Order matters.

The CTEs are correct. The final SELECT counts the wrong thing.

Tables
customers
(customer_id, first_name, last_name, email, country, city, signup_date, segment, referred_by)
orders
(order_id, customer_id, order_date, status, channel, coupon_code, shipping_fee)
order_items
(order_item_id, order_id, product_id, quantity, unit_price, discount)
products
(product_id, name, category_id, unit_price, unit_cost, launched_on, is_active)
categories
(category_id, name, parent_id)
returns
(return_id, order_item_id, return_date, reason, refund_amount)
employees
(employee_id, first_name, last_name, title, department, manager_id, hire_date, salary)
support_tickets
(ticket_id, customer_id, order_id, opened_at, closed_at, priority, channel, satisfaction, agent_id)
web_sessions
(session_id, customer_id, started_at, source, device, pages_viewed, added_to_cart, purchased)
-- Share of each 2025 cohort that ordered again within 3 months
WITH orders_ok AS (
  SELECT customer_id,
         order_date,
         CAST(strftime('%Y', order_date) AS INTEGER) * 12
           + CAST(strftime('%m', order_date) AS INTEGER) AS month_index
  FROM orders
  WHERE status <> 'cancelled'
),
firsts AS (
  SELECT customer_id,
         MIN(month_index) AS cohort_index,
         strftime('%Y-%m', MIN(order_date)) AS cohort
  FROM orders_ok
  GROUP BY customer_id
),
activity AS (
  SELECT f.cohort, o.customer_id, o.month_index - f.cohort_index AS month_number
  FROM orders_ok AS o
  JOIN firsts AS f ON f.customer_id = o.customer_id
)
SELECT
  cohort,
  COUNT(*) AS customers,
  ROUND(100.0 * COUNT(*) FILTER (WHERE month_number BETWEEN 1 AND 3)
        / COUNT(*), 1) AS repeat_3m_pct
FROM activity
WHERE cohort BETWEEN '2025-01' AND '2025-09'
GROUP BY cohort
ORDER BY cohort;Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.