Когортный анализ в SQL — практический гайд
Зачем писать когорты в SQL
Когортный анализ — один из главных инструментов продуктовой аналитики. Если вы знаете теорию, но не можете написать SQL-запрос — на собеседовании это заметят. Теоретическую базу мы разбирали в основном гайде по когортам. Здесь — чистая практика: SQL-запросы, которые можно адаптировать под свои данные.
Задача: построить таблицу retention по недельным когортам. Каждая строка — когорта (неделя регистрации), каждый столбец — день (D1, D7, D14, D30), значения — процент вернувшихся пользователей.
Шаг 1: определяем когорту каждого пользователя
Когорта — это группа пользователей с одинаковой датой первого действия. Находим дату первого визита и округляем до недели.
WITH cohort AS (
SELECT
user_id,
MIN(event_date) AS first_date,
DATE_TRUNC('week', MIN(event_date))::DATE AS cohort_week
FROM user_activity
GROUP BY user_id
)DATE_TRUNC('week', ...) округляет до понедельника. Если нужны месячные когорты — замените на 'month'.
Важно: используйте именно MIN(event_date), а не дату регистрации из таблицы users. Регистрация и первая активность — не всегда одно и то же.
Шаг 2: считаем day_number для каждого визита
Для каждого последующего визита пользователя вычисляем, сколько дней прошло с его первого визита.
activity AS (
SELECT DISTINCT user_id, event_date
FROM user_activity
),
retention_data AS (
SELECT
c.cohort_week,
c.user_id,
a.event_date - c.first_date AS day_number
FROM cohort c
INNER JOIN activity a ON c.user_id = a.user_id
)DISTINCT в activity убирает дубли — если пользователь пришёл дважды за день, это один возврат. day_number = 0 — день первого визита, day_number = 1 — следующий день и т.д.
Шаг 3: агрегируем в когортную таблицу
Для каждой когорты считаем размер и процент вернувшихся на каждый день.
SELECT
cohort_week,
COUNT(DISTINCT user_id) AS cohort_size,
ROUND(100.0 * COUNT(DISTINCT CASE WHEN day_number = 1
THEN user_id END) / COUNT(DISTINCT user_id), 1) AS d1,
ROUND(100.0 * COUNT(DISTINCT CASE WHEN day_number = 7
THEN user_id END) / COUNT(DISTINCT user_id), 1) AS d7,
ROUND(100.0 * COUNT(DISTINCT CASE WHEN day_number = 14
THEN user_id END) / COUNT(DISTINCT user_id), 1) AS d14,
ROUND(100.0 * COUNT(DISTINCT CASE WHEN day_number = 30
THEN user_id END) / COUNT(DISTINCT user_id), 1) AS d30
FROM retention_data
WHERE cohort_week <= CURRENT_DATE - INTERVAL '30 days'
GROUP BY cohort_week
ORDER BY cohort_week;COUNT(DISTINCT CASE WHEN day_number = 7 THEN user_id END) — число уникальных пользователей, которые вернулись ровно на 7-й день. Делим на размер когорты — получаем D7 retention.
Фильтр WHERE cohort_week <= CURRENT_DATE - INTERVAL '30 days' отсекает когорты, у которых D30 ещё не наступил. Без него последние строки покажут 0% и исказят картину.
Полный запрос целиком
Готовый шаблон — скопируйте и подставьте свои таблицы и поля.
WITH cohort AS (
SELECT
user_id,
MIN(event_date) AS first_date,
DATE_TRUNC('week', MIN(event_date))::DATE AS cohort_week
FROM user_activity
GROUP BY user_id
),
activity AS (
SELECT DISTINCT user_id, event_date
FROM user_activity
),
retention_data AS (
SELECT
c.cohort_week,
c.user_id,
a.event_date - c.first_date AS day_number
FROM cohort c
INNER JOIN activity a ON c.user_id = a.user_id
)
SELECT
cohort_week,
COUNT(DISTINCT user_id) AS cohort_size,
ROUND(100.0 * COUNT(DISTINCT CASE WHEN day_number = 1
THEN user_id END) / COUNT(DISTINCT user_id), 1) AS d1,
ROUND(100.0 * COUNT(DISTINCT CASE WHEN day_number = 3
THEN user_id END) / COUNT(DISTINCT user_id), 1) AS d3,
ROUND(100.0 * COUNT(DISTINCT CASE WHEN day_number = 7
THEN user_id END) / COUNT(DISTINCT user_id), 1) AS d7,
ROUND(100.0 * COUNT(DISTINCT CASE WHEN day_number = 14
THEN user_id END) / COUNT(DISTINCT user_id), 1) AS d14,
ROUND(100.0 * COUNT(DISTINCT CASE WHEN day_number = 30
THEN user_id END) / COUNT(DISTINCT user_id), 1) AS d30
FROM retention_data
WHERE cohort_week <= CURRENT_DATE - INTERVAL '30 days'
GROUP BY cohort_week
ORDER BY cohort_week;Если нужны оконные функции для более сложных задач — например, кумулятивный retention или скользящее среднее по когортам, — их можно добавить поверх этого запроса.
Как читать результат
| cohort_week | cohort_size | d1 | d7 | d14 | d30 |
|---|---|---|---|---|---|
| 2026-02-03 | 320 | 24.1 | 11.3 | 7.2 | 4.1 |
| 2026-02-10 | 285 | 27.4 | 13.0 | 8.8 | 5.3 |
| 2026-02-17 | 310 | 31.0 | 15.8 | 10.6 | 6.8 |
| 2026-02-24 | 340 | 30.3 | 14.7 | 9.9 | — |
Читайте по столбцам: D7 растёт (11.3 → 13.0 → 15.8) — продукт улучшается от когорты к когорте. Последняя строка без D30 — когорта ещё не «дожила» до 30 дней.
Rolling retention vs classic retention
Запрос выше считает classic (day-N) retention — вернулся ли пользователь ровно на N-й день. Есть альтернатива — rolling retention (bounded retention): вернулся ли пользователь на N-й день или позже.
-- Rolling retention: user_id вернулся на день N или позже
ROUND(100.0 * COUNT(DISTINCT CASE WHEN day_number >= 7
THEN user_id END) / COUNT(DISTINCT user_id), 1) AS rolling_d7Rolling retention всегда >= classic retention, потому что включает всех, кто пришёл позже. Он лучше подходит для продуктов с нерегулярным использованием (e-commerce, сервисы бронирования), где пользователь может вернуться через 10 дней и это нормально. Classic retention — для продуктов ежедневного использования.
Подробнее про разные виды retention — в гайде по retention и retention в SQL.
Типичные ошибки
1. Не отсекать незавершённые когорты. Без фильтра по дате последние когорты покажут 0% на дальних днях. Это создаёт ложное впечатление деградации.
2. Считать retention по сессиям, а не по пользователям. Один активный пользователь с 10 сессиями исказит картину. Используйте COUNT(DISTINCT user_id).
3. Путать день когорты и день активности. day_number — это разница между датой активности и датой первого визита пользователя, а не датой когорты. Ошибка в одну неделю (если когорта — понедельник, а пользователь пришёл в пятницу) сломает все расчёты.
4. Слишком мелкие когорты. Если в когорте 20 человек, один вернувшийся — это 5%. Два — 10%. Случайные колебания будут огромными. Укрупняйте: дни → недели → месяцы.
5. Не учитывать воронку. Когортный анализ показывает retention, но не объясняет почему пользователи уходят. Комбинируйте с воронкой: на каком шаге отваливаются внутри сессии.
Вопросы с собеседований
— Напишите SQL-запрос для когортной таблицы retention. — Три CTE: (1) cohort — MIN(event_date) и DATE_TRUNC для определения когорты, (2) activity — DISTINCT user_id + event_date, (3) retention_data — JOIN с вычислением day_number. Финальный SELECT с COUNT(DISTINCT CASE WHEN day_number = N ...) для каждого дня. Не забыть отфильтровать незавершённые когорты.
— Чем rolling retention отличается от classic retention? — Classic retention (day-N) считает, вернулся ли пользователь ровно на N-й день. Rolling retention — вернулся на N-й день или позже. Rolling >= classic. Classic лучше для ежедневных продуктов, rolling — для продуктов с нерегулярным использованием.
— D7 retention последней когорты резко упал. Как будете разбираться? — Проверить размер когорты. Сегментировать по платформе, источнику трафика, стране. Проверить, не было ли технических проблем (сбои, баги). Сравнить состав когорты с предыдущими — возможно, изменился канал привлечения.
— Как адаптировать запрос для месячных когорт?
— Заменить DATE_TRUNC('week', ...) на DATE_TRUNC('month', ...) и вычислять month_number вместо day_number: EXTRACT(YEAR FROM AGE(...)) * 12 + EXTRACT(MONTH FROM AGE(...)). Фильтр незавершённых когорт тоже сдвигается.
FAQ
Какую гранулярность когорт выбрать — дни, недели, месяцы?
Зависит от продукта и трафика. Для мобильных приложений с ежедневным использованием — недели. Для SaaS — месяцы. Для продуктов с маленьким трафиком (< 100 регистраций в неделю) укрупняйте до месяцев, иначе когорты будут слишком маленькими для выводов.
Можно ли строить когорты не по дате регистрации?
Да. Когорты можно строить по любому первому событию: первая покупка, первое прохождение онбординга, первое использование фичи. Также можно строить поведенческие когорты — по действию, а не по дате. Например, «прошли онбординг» vs «не прошли». Подробнее — в основном гайде.
Как визуализировать когортную таблицу?
Лучший формат — heatmap (тепловая карта). В Python — seaborn.heatmap(), в Google Sheets — условное форматирование, в BI-инструментах — встроенные шаблоны. Обязательно подписывайте размер когорты рядом с процентами, чтобы не обманываться маленькими выборками.
Потренируйте вопросы по SQL — откройте тренажёр. 1500+ вопросов для собеседования аналитика. Бесплатно.