SQL-задачи на retention, воронки и когорты — с решениями
Зачем решать задачи, а не просто читать теорию
На собеседовании аналитика дают таблицы и просят написать запрос. Не объяснить концепцию, не нарисовать схему — а выдать рабочий SQL. Теория retention, воронок и когорт помогает понять, что считать. Но без практики руки не напишут WITH ... AS за 15 минут под давлением. Здесь — 5 задач уровня middle, которые регулярно встречаются на собесах. PostgreSQL, с решениями и разборами.
Если хотите сначала разобраться в теории — начните с гайдов: retention в SQL, воронка конверсии, когортный анализ.
Задача 1. D7 retention по недельным когортам
Условие. Таблица activity(user_id, event_date). Посчитайте D7 retention по недельным когортам: сгруппируйте пользователей по неделе первого визита, найдите долю тех, кто вернулся ровно через 7 дней.
Подсказка. date_trunc('week', ...) превращает дату в начало недели. Когорта — это MIN(event_date) каждого пользователя.
Решение
WITH cohorts AS (
SELECT
user_id,
date_trunc('week', MIN(event_date))::DATE AS cohort_week
FROM activity
GROUP BY user_id
),
cohort_size AS (
SELECT cohort_week, COUNT(*) AS total_users
FROM cohorts
GROUP BY cohort_week
),
first_visit AS (
SELECT user_id, MIN(event_date) AS first_date
FROM activity
GROUP BY user_id
),
returned AS (
SELECT DISTINCT c.cohort_week, f.user_id
FROM first_visit f
JOIN cohorts c ON c.user_id = f.user_id
JOIN activity a ON a.user_id = f.user_id
AND a.event_date = f.first_date + 7
)
SELECT
cs.cohort_week,
cs.total_users,
COUNT(r.user_id) AS returned_d7,
ROUND(100.0 * COUNT(r.user_id) / cs.total_users, 1) AS retention_d7_pct
FROM cohort_size cs
LEFT JOIN returned r ON r.cohort_week = cs.cohort_week
GROUP BY cs.cohort_week, cs.total_users
ORDER BY cs.cohort_week;Проще и надёжнее — через day_offset:
WITH cohorts AS (
SELECT user_id, MIN(event_date) AS first_date
FROM activity
GROUP BY user_id
),
cohort_week AS (
SELECT
user_id,
first_date,
date_trunc('week', first_date)::DATE AS cohort_week
FROM cohorts
),
cohort_size AS (
SELECT cohort_week, COUNT(*) AS total_users
FROM cohort_week
GROUP BY cohort_week
)
SELECT
cw.cohort_week,
cs.total_users,
COUNT(DISTINCT CASE
WHEN a.event_date - cw.first_date = 7 THEN cw.user_id
END) AS returned_d7,
ROUND(100.0 *
COUNT(DISTINCT CASE
WHEN a.event_date - cw.first_date = 7 THEN cw.user_id
END) / cs.total_users, 1
) AS retention_d7_pct
FROM cohort_week cw
JOIN activity a ON a.user_id = cw.user_id
JOIN cohort_size cs ON cs.cohort_week = cw.cohort_week
GROUP BY cw.cohort_week, cs.total_users
ORDER BY cw.cohort_week;Разбор. Ключевой момент — first_date определяется для каждого пользователя индивидуально, а cohort_week — это группировка для отчёта. D7 retention = «вернулся ровно через 7 дней после своего первого визита». CASE WHEN ... THEN user_id END внутри COUNT(DISTINCT ...) — стандартный приём для условного подсчёта.
Задача 2. Воронка: визит -> корзина -> покупка
Условие. Таблица events(user_id, event_type, created_at). Значения event_type: 'visit', 'cart', 'purchase'. Постройте воронку: сколько уникальных пользователей на каждом шаге и конверсия относительно предыдущего шага.
Подсказка. Каждый шаг — отдельный COUNT(DISTINCT ...). Конверсия = текущий шаг / предыдущий шаг.
Решение
WITH steps AS (
SELECT
COUNT(DISTINCT CASE WHEN event_type = 'visit' THEN user_id END) AS visits,
COUNT(DISTINCT CASE WHEN event_type = 'cart' THEN user_id END) AS carts,
COUNT(DISTINCT CASE WHEN event_type = 'purchase' THEN user_id END) AS purchases
FROM events
)
SELECT
visits,
carts,
purchases,
ROUND(100.0 * carts / NULLIF(visits, 0), 1) AS visit_to_cart_pct,
ROUND(100.0 * purchases / NULLIF(carts, 0), 1) AS cart_to_purchase_pct,
ROUND(100.0 * purchases / NULLIF(visits, 0), 1) AS overall_conversion_pct
FROM steps;Разбор. NULLIF(visits, 0) защищает от деления на ноль. Воронка здесь «нестрогая»: пользователь считается на шаге, даже если попал туда без предыдущего шага. Для строгой воронки (cart только если был visit) нужен JOIN между шагами. Подробнее — в статье воронка конверсии в SQL.
Задача 3. Month-over-month рост выручки
Условие. Таблица orders(order_id, user_id, amount, created_at). Посчитайте выручку по месяцам и процент изменения относительно предыдущего месяца (MoM growth).
Подсказка. Оконная функция LAG() даёт доступ к значению предыдущей строки.
Решение
WITH monthly_revenue AS (
SELECT
date_trunc('month', created_at)::DATE AS month,
SUM(amount) AS revenue
FROM orders
GROUP BY date_trunc('month', created_at)
)
SELECT
month,
revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_month_revenue,
ROUND(
100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
/ NULLIF(LAG(revenue) OVER (ORDER BY month), 0),
1
) AS mom_growth_pct
FROM monthly_revenue
ORDER BY month;Разбор. LAG(revenue) OVER (ORDER BY month) возвращает выручку предыдущего месяца. Первая строка получит NULL — предыдущего месяца нет, и это корректное поведение. MoM growth = (текущий - предыдущий) / предыдущий * 100. На собеседовании могут попросить добавить фильтр «только последние 12 месяцев» — это простой WHERE created_at >= now() - interval '12 months'.
Задача 4. Покупка в течение 24 часов после регистрации
Условие. Таблицы users(user_id, registered_at) и orders(order_id, user_id, created_at). Найдите пользователей, которые совершили покупку в течение 24 часов после регистрации. Выведите user_id, время регистрации, время первой покупки и разницу в часах.
Подсказка. EXTRACT(EPOCH FROM ...) переводит интервал в секунды. Делим на 3600 — получаем часы.
Решение
WITH first_purchase AS (
SELECT
user_id,
MIN(created_at) AS first_order_at
FROM orders
GROUP BY user_id
)
SELECT
u.user_id,
u.registered_at,
fp.first_order_at,
ROUND(
EXTRACT(EPOCH FROM fp.first_order_at - u.registered_at) / 3600.0,
1
) AS hours_to_purchase
FROM users u
JOIN first_purchase fp ON fp.user_id = u.user_id
WHERE fp.first_order_at <= u.registered_at + INTERVAL '24 hours'
ORDER BY hours_to_purchase;Разбор. MIN(created_at) — берём именно первую покупку, а не любую. Условие <= registered_at + interval '24 hours' отсекает тех, кто купил позже. EXTRACT(EPOCH FROM ...) — стандартный способ получить секунды из интервала в PostgreSQL. Эту задачу часто дают, чтобы проверить, умеете ли вы работать с timestamp-арифметикой.
Задача 5. Кумулятивная выручка по когортам
Условие. Таблицы users(user_id, registered_at) и orders(order_id, user_id, amount, created_at). Для каждой месячной когорты регистрации посчитайте кумулятивную выручку по месяцам жизни когорты (месяц 0, 1, 2, ...).
Подсказка. «Месяц жизни» когорты — разница между месяцем заказа и месяцем регистрации. Кумулятивная сумма — SUM() OVER (PARTITION BY ... ORDER BY ...).
Решение
WITH user_cohort AS (
SELECT
user_id,
date_trunc('month', registered_at)::DATE AS cohort_month
FROM users
),
cohort_orders AS (
SELECT
uc.cohort_month,
(EXTRACT(YEAR FROM age(date_trunc('month', o.created_at), uc.cohort_month)) * 12
+ EXTRACT(MONTH FROM age(date_trunc('month', o.created_at), uc.cohort_month))
)::int AS lifetime_month,
o.amount
FROM user_cohort uc
JOIN orders o ON o.user_id = uc.user_id
WHERE o.created_at >= uc.cohort_month
),
monthly_rev AS (
SELECT
cohort_month,
lifetime_month,
SUM(amount) AS revenue
FROM cohort_orders
GROUP BY cohort_month, lifetime_month
)
SELECT
cohort_month,
lifetime_month,
revenue,
SUM(revenue) OVER (
PARTITION BY cohort_month
ORDER BY lifetime_month
) AS cumulative_revenue
FROM monthly_rev
ORDER BY cohort_month, lifetime_month;Разбор. age() возвращает интервал между двумя датами. Извлекаем из него годы и месяцы, чтобы получить целое число — номер месяца жизни когорты. SUM(...) OVER (PARTITION BY cohort_month ORDER BY lifetime_month) — это кумулятивная (нарастающая) сумма внутри каждой когорты. Результат показывает, сколько денег принесла каждая когорта к концу каждого месяца — основа для расчёта LTV. Подробнее о когортах — в статье когортный анализ.
Что дальше
Эти 5 задач покрывают самые частые темы на собеседованиях: retention, воронки, MoM-динамику, временные интервалы и когортный LTV. Если хотите больше задач — в тренажёре есть 300+ SQL-вопросов разного уровня, а полный список тем — на странице примеры вопросов.
Лучший способ закрепить — решать руками. Откройте тренажёр и прорешайте SQL-блок — там есть задачи на все темы из этой статьи.