Все материалы
Продуктовая аналитикагайдсредний

Когорты и retention в SQL: как считать возвращаемость по датам

Разбираем когортный retention в SQL: как определить дату старта, посчитать D1 и D7, собрать матрицу возвращаемости и сравнить каналы без самообмана.

КПКейсПрактика19 июля 2026 г.19 мин

Retention отвечает на вопрос, вернулся ли пользователь после первого опыта с продуктом. В отличие от общего DAU, он связывает поведение с датой старта: пользователи, пришедшие в разные дни, попадают в разные когорты. SQL здесь нужен не ради сложной конструкции, а чтобы честно определить старт, событие возврата и возраст когорты.

Коротко

Когорта — группа пользователей с общей датой или периодом старта. Retention показывает долю этой группы, которая снова совершила выбранное действие через D1, D7, D30 или другой период. Сначала считаем пользователей, потом переводим их в доли от размера исходной когорты.

  • Дата старта и событие возврата должны быть определены отдельно.
  • Размер когорты — число уникальных пользователей, а не событий.
  • D1 означает активность на первый день после старта, если так договорилась команда.
  • Незрелые когорты нельзя сравнивать с когортиками, у которых уже прошли 30 дней.
  • Сравнивай retention по каналам только при одинаковом определении событий и окна.

Как определить когорту и возврат

Для простого примера дата когорты — users.signup_date, а возвращение — событие core_action в таблице events. В реальном продукте стартом может быть первая активация, первая сессия или первый заказ. Если взять регистрацию для одного продукта и первую ценность для другого, retention станет несопоставимым.

Два определения в основе retention
КомпонентПримерЧто проверить
Дата стартаsignup_dateпользователь действительно начал сценарий?
Событие возвратаcore_actionдоказывает ли действие получение ценности?
Возрастevent_date - signup_dateсчитаем календарные дни или 24 часа?
Знаменательразмер когортывсе ли пользователи имеют шанс дожить до дня?

Сначала получи активные дни пользователя

Таблица events содержит много строк на одного пользователя. Перед расчётом retention уберём повторы: для каждой пары “пользователь × день после старта” оставим одну строку. Это важно, потому что пять core actions в один день не должны превратить одного вернувшегося пользователя в пять.

Уникальные активные дни относительно регистрации
with active_days as (
  select distinct
    u.user_id,
    u.signup_date as cohort_date,
    e.event_date,
    e.event_date - u.signup_date as days_since_signup
  from users as u
  join events as e using (user_id)
  where e.event_name = 'core_action'
    and e.event_date >= u.signup_date
)
select *
from active_days
order by cohort_date, user_id, event_date;
Один день — одна отметка

Если продукт хранит timestamp, сначала приведи событие к бизнесовой дате в нужной таймзоне. Иначе события около полуночи могут попасть в разные дни и исказить D1.

D1 и D7 по когортам

Теперь можно посчитать размер каждой когорты и число пользователей, активных на первом и седьмом дне. В запросе COUNT(DISTINCT user_id) защищает от повторных событий, а деление на размер когорты превращает количество в retention rate.

В cohort_users одна строка означает одного пользователя, поэтому max(case...) превращает любое подходящее событие в одну отметку. LEFT JOIN сохраняет пользователей, которые не вернулись: без них знаменатель когорты стал бы слишком маленьким.

D1 и D7 retention для когорт регистрации
with cohort_users as (
  select
    u.user_id,
    u.signup_date as cohort_date,
    u.channel,
    max(
      case when e.event_date - u.signup_date = 1
        then 1 else 0 end
    ) as retained_d1,
    max(
      case when e.event_date - u.signup_date = 7
        then 1 else 0 end
    ) as retained_d7
  from users as u
  left join events as e
    on e.user_id = u.user_id
   and e.event_name = 'core_action'
   and e.event_date >= u.signup_date
  group by u.user_id, u.signup_date, u.channel
),
cohort_summary as (
  select
    cohort_date,
    count(*) as cohort_size,
    sum(retained_d1) as d1_users,
    sum(retained_d7) as d7_users
  from cohort_users
  group by cohort_date
)
select
  cohort_date,
  cohort_size,
  round(d1_users::numeric / nullif(cohort_size, 0), 3) as d1_retention,
  round(d7_users::numeric / nullif(cohort_size, 0), 3) as d7_retention
from cohort_summary
order by cohort_date;

Retention-матрица: когорта × возраст

Для heatmap обычно нужны не только D1 и D7, а значения по нескольким возрастам: D0, D1, D3, D7, D14 и D30. В строках лежит когорта, в колонках — возраст пользователя. Цвет помогает быстро заметить когорту, которая стала возвращаться хуже после релиза или смены канала.

Иллюстрация retention-матрицы

Значения показаны для объяснения формы отчёта. В рабочей матрице нужны реальные когорты и зрелость каждого столбца.

1 июн
1 240
D0
100%
D1
42%
D3
28%
D7
19%
D14
13%
2 июн
1 180
D0
100%
D1
39%
D3
25%
D7
17%
D14
11%
3 июн
1 310
D0
100%
D1
41%
D3
27%
D7
18%
D14
4 июн
1 090
D0
100%
D1
34%
D3
21%
D7
D14
Пустая ячейка не равна нулю

Если когорте 4 июня ещё не исполнилось 14 дней, D14 нужно оставить пустым. Ноль означает “никто не вернулся”, а пустая ячейка — “данных для этого возраста ещё нет”.

Сравни retention по каналам аккуратно

Канал с высоким D7 не обязательно лучший, если он приводит очень мало пользователей или дорогой трафик. Но разрез по каналам помогает увидеть качество привлечения: где люди не только регистрируются, но и возвращаются к core action.

В отчёте показывай и размер когорты, и число вернувшихся. Канал с D7 35% на 20 пользователях и канал с D7 22% на 2 000 пользователях требуют разных решений и разного уровня уверенности.

D7 retention по каналам регистрации
with user_retention as (
  select
    u.user_id,
    u.channel,
    max(
      case when e.event_date - u.signup_date = 7
        then 1 else 0 end
    ) as retained_d7
  from users as u
  left join events as e
    on e.user_id = u.user_id
   and e.event_name = 'core_action'
   and e.event_date >= u.signup_date
  group by u.user_id, u.channel
)
select
  channel,
  count(*) as cohort_size,
  sum(retained_d7) as d7_users,
  round(sum(retained_d7)::numeric / nullif(count(*), 0), 3) as d7_retention
from user_retention
group by channel
order by d7_retention desc;

Частые ошибки в retention

Retention выглядит убедительно даже тогда, когда определение спрятано в деталях. Поэтому рядом с каждой heatmap или строкой отчёта должны быть дата старта, return event, таймзона и правило зрелости когорты.

  • Считать события вместо уникальных пользователей.
  • Включить активность до даты регистрации из-за неверного JOIN.
  • Считать D7 для свежих когорт как ноль вместо незрелого значения.
  • Использовать регистрацию как старт для продукта, где ценность появляется позже.
  • Смешать календарный D1 и rolling 24 hours в одном отчёте.
  • Сравнивать каналы без размера когорты и стоимости привлечения.
  • Не исключить тестовые аккаунты, внутренние команды и технические события.

Зрелость когорты — обязательное поле

Retention D30 можно считать только для пользователей, у которых прошло 30 дней наблюдения. Свежая когорта не имеет значения D30, а не имеет нулевой retention. Если поставить ноль, последние когорты всегда будут выглядеть хуже старых и создадут ложный сигнал о падении продукта.

Добавь в результат cohort_date, cohort_size, age_days, returned_users, retention и is_mature. Тогда heatmap можно фильтровать по зрелости, а читатель понимает, почему часть ячеек пустая. Это также помогает сравнить отчёт с продуктовой аналитикой, где когорты часто определяются иначе.

Когорту лучше хранить на уровне пользователя, а не сразу сворачивать в проценты. Пользовательская таблица позволяет повторно посчитать D1, D7, D30, изменить return event и проверить сегменты без повторного чтения сырых событий.

Минимальные поля cohort-level результата
ПолеЗачем
cohort_dateдата старта пользователя
cohort_sizeзнаменатель retention
age_daysвозраст когорты
returned_usersчислитель
is_matureможно ли сравнивать возраст с историей

Retention и reactivation — не одно и то же

Если пользователь вернулся через 60 дней после старта, это важный сигнал, но он не должен автоматически попадать в D7. Для retention нужно заранее определить окно и событие возврата. Отдельно можно считать reactivation: долю пользователей, которые были неактивны заданное число дней и снова совершили core action.

Разные определения отвечают на разные вопросы. D7 показывает раннее закрепление привычки, D30 — более длинное удержание, reactivation — возможность вернуть ушедшую аудиторию. Не смешивай их в одну кривую и не называй любой повторный event retention.

Для команды полезно показывать рядом cohort retention и календарную активность. Если D7 стабилен, но общий DAU падает, проблема может быть в привлечении новых пользователей. Если новые когорты хуже старых, ищи изменения онбординга, каналов или продукта.

Сначала назвать событие возврата

login, app_open и core_action дадут разные retention. В заголовке отчёта или подписи heatmap укажи, какое действие считается возвращением.

Три способа считать retention, которые дают разные числа

Прежде чем спорить о значении, стоит договориться о формуле. Под словом retention скрываются как минимум три разных расчёта, и они честно дают разные проценты на одних и тех же данных.

N-day retention — самый строгий: пользователь засчитывается, только если был активен именно в день N. Он хорошо ловит привычку, но занижает результат у продуктов, которыми пользуются нерегулярно: человек зашёл на шестой и восьмой день, а в D7 не попал.

Unbounded, он же rolling retention, засчитывает пользователя, если тот был активен в день N или позже. Число получается выше и лучше подходит там, где цикл использования редкий — например, продукту, которым пользуются раз в месяц. Обратная сторона: метрика меняется задним числом, потому что вчерашний «не вернувшийся» может вернуться завтра.

Bracket retention считает активность в интервале — например, между третьим и седьмым днём. Это компромисс: он терпимее к нерегулярности, чем N-day, и не переписывает прошлое, как unbounded.

Ни один из трёх не правильнее других. Неправильно другое — сравнивать числа, посчитанные по разным формулам, и не писать рядом, какая использована.

Одна когорта, три формулы
ФормулаКто засчитанКому подходит
N-dayактивен ровно в день Nежедневные продукты, привычка
Unbounded / rollingактивен в день N или позжередкий цикл использования
Bracketактивен в интервале днейкомпромисс, устойчив к нерегулярности

Сквозной пример: D1 по дням регистрации на учебной базе

Посчитаем retention целиком на базе SQL-тренажёра. Продукт «Маяк», первая неделя июня, восемнадцать пользователей — по три в день с 1 по 6 июня. Когортой будет день регистрации, возвратом — любое событие на следующий день.

Расчёт распадается на три шага, и каждый проверяется отдельно. Сначала получаем активные дни пользователя — по одной строке на пару «человек + дата». Затем к каждому пользователю приписываем дату его регистрации. И наконец считаем, у кого нашлась активность ровно на день позже.

Результат по учебной базе выглядит так: из 18 пользователей на следующий день вернулись 10, то есть D1 равен 55,6%. Но общее число здесь наименее интересное — разрез по дням показывает то, чего среднее не видно.

D1 по когортам
КогортаРазмерВернулись на D1D1
1 июня33100%
2 июня3266,7%
3 июня3266,7%
4 июня3133,3%
5 июня3133,3%
6 июня3133,3%
D1 retention по когортам дня регистрации
WITH active_days AS (          -- зерно: пользователь + день
  SELECT DISTINCT user_id, CAST(event_time AS DATE) AS day
  FROM events
)
SELECT
  u.signup_date                                   AS cohort_date,
  count(*)                                        AS cohort_size,
  count(a.user_id)                                AS returned_d1,
  round(100.0 * count(a.user_id) / count(*), 1)   AS d1_retention
FROM users u
LEFT JOIN active_days a
  ON a.user_id = u.user_id
  AND a.day = u.signup_date + INTERVAL '1 day'
GROUP BY u.signup_date
ORDER BY u.signup_date;

Почему нельзя объявлять, что retention упал втрое

Соблазн прочитать эту таблицу как обвал велик: 100% в начале недели и 33% в конце. На слайде это выглядело бы как катастрофа с ясным виновником — «что-то сломали в среду».

Но у поздних когорт есть техническая причина выглядеть хуже. База обрывается 7 июня: у когорты 6 июня для D1 есть ровно один день наблюдения, и он последний, а значит неполный. Кроме того, каждая когорта здесь — три человека. Один вернувшийся пользователь двигает метрику на 33 пункта, поэтому разница между 66,7% и 33,3% — это разница в одного человека.

Правило простое и жёсткое: когорта попадает в сравнение только тогда, когда прожила весь период наблюдения. Для D1 это сутки после регистрации, для D7 — неделя. Незрелые когорты не показывают нулём или маленьким числом — их либо исключают, либо помечают явно, оставляя ячейку пустой.

То же касается размера. Процент на трёх наблюдениях не метрика, а анекдот; рядом с ним обязаны стоять числитель и знаменатель, чтобы никто не строил на нём план квартала.

Только зрелые когорты, незрелые — NULL
WITH bounds AS (              -- граница наблюдения = последний день данных
  SELECT max(CAST(event_time AS DATE)) AS last_day FROM events
),
active_days AS (
  SELECT DISTINCT user_id, CAST(event_time AS DATE) AS day FROM events
)
SELECT
  u.signup_date AS cohort_date,
  count(*)      AS cohort_size,
  CASE
    WHEN u.signup_date + INTERVAL '1 day' <= b.last_day
    THEN round(100.0 * count(a.user_id) / count(*), 1)
  END           AS d1_retention
FROM users u
CROSS JOIN bounds b
LEFT JOIN active_days a
  ON a.user_id = u.user_id AND a.day = u.signup_date + INTERVAL '1 day'
GROUP BY u.signup_date, b.last_day
ORDER BY u.signup_date;

Форма кривой важнее одной точки

Retention редко имеет смысл как единственное число. Гораздо больше говорит форма кривой по возрасту когорты, и у здорового продукта она состоит из трёх участков.

Первый — резкое падение в первые дни. Это нормально: часть пришедших никогда не собиралась пользоваться продуктом всерьёз. Глубина этого падения говорит о качестве трафика и первого опыта, а не о продукте в целом.

Второй — выход на плато. Если кривая где-то выравнивается, значит существует ядро аудитории, для которой продукт стал частью работы. Уровень плато — самая содержательная цифра во всём расчёте: именно она определяет, растёт ли база при постоянном притоке новых пользователей.

Третий — медленное сползание хвоста. Оно всегда есть, вопрос в скорости. Кривая без плато, уходящая в ноль, означает, что продукт не удерживает никого: сколько людей ни приводи, база не накопится, потому что старые уходят быстрее, чем приходят новые.

Отсюда практический вывод: сравнивать когорты нужно по форме, а не по отдельной точке. Две когорты с одинаковым D1, но разным уровнем плато — это две разные истории, и решения по ним принимаются противоположные.

Retention и деньги: зачем считать удержание платящих отдельно

Удержание всей аудитории и удержание тех, кто платит, — разные метрики с разными выводами. Продукт может уверенно держать бесплатных пользователей и терять платящих, и общая кривая этого не покажет: платящих обычно в разы меньше, они растворяются в среднем.

Практика простая: считайте ту же кривую отдельно по сегменту плательщиков. На учебной базе таких восемь из восемнадцати, и по ним видно то, что для бизнеса важнее всего, — возвращаются ли люди после того, как заплатили. Retention платящих напрямую связан с окупаемостью привлечения: чем дольше живёт когорта, тем больше платежей она успевает сделать.

Второй разрез — по тарифу или размеру платежа. Если удержание на дешёвом тарифе заметно ниже, это может значить, что он привлекает не ту аудиторию либо не даёт достаточной ценности. Такой вопрос решается изменением продукта или упаковки, а не рекламным бюджетом.

Связывать retention с выручкой напрямую в одном запросе не обязательно — достаточно считать обе метрики на одном определении когорты. Тогда их можно положить рядом: сколько людей осталось и сколько денег они принесли за тот же период.

Удержание платящих в сравнении со всей аудиторией
WITH active_days AS (
  SELECT DISTINCT user_id, CAST(event_time AS DATE) AS day FROM events
),
payers AS (
  SELECT DISTINCT user_id FROM payments
)
SELECT
  CASE WHEN p.user_id IS NULL THEN 'без оплаты' ELSE 'платящие' END AS segment,
  count(*)                                      AS users,
  count(a.user_id)                              AS returned_d1,
  round(100.0 * count(a.user_id) / count(*), 1) AS d1_retention
FROM users u
LEFT JOIN payers p ON p.user_id = u.user_id
LEFT JOIN active_days a
  ON a.user_id = u.user_id AND a.day = u.signup_date + INTERVAL '1 day'
GROUP BY segment
ORDER BY segment;

Retention отвечает не на все вопросы про удержание

У метрики есть границы, о которых полезно помнить до того, как её вынесут на встречу.

Она не различает интенсивность. Пользователь, зашедший на минуту, и пользователь, отработавший в продукте день, одинаково засчитываются как вернувшиеся. Если это важно, рядом ставят вторую метрику — частоту сессий или объём целевого действия.

Она не объясняет причину ухода. Из данных видно, что человек не вернулся; почему — знают только он и, возможно, запись его сессии. Retention хорошо показывает, где смотреть, и плохо — что чинить.

И она сильно зависит от определения возврата. Если возвращением считать любое событие, метрика будет высокой и малоинформативной: автоматические открытия приложения и фоновые события засчитаются как активность. Если считать целевое действие — число упадёт, зато станет связано с ценностью. Первое годится для мониторинга технической активности, второе — для продуктовых решений, и путать их не стоит.

Что делать, когда когорты слишком маленькие

Проблема учебной базы — не учебная. У молодых продуктов и узких сегментов когорты действительно бывают по несколько человек, и retention по дням превращается в шум.

Первое решение — укрупнить когорту: перейти с дней на недели или месяцы регистрации. Метрика станет спокойнее, а сравнение — осмысленнее. Второе — сместить фокус с процента на абсолютные числа: «из 12 зарегистрировавшихся вернулись 4» честнее, чем «33,3%». Третье — смотреть на накопленную кривую по всем когортам сразу, если вопрос не «когда стало хуже», а «как в принципе устроено удержание».

И отдельно: маленькая когорта не повод отказаться от расчёта. Повод — не строить на нём выводов о причинах. Ранний retention полезно считать даже на десятках пользователей, чтобы поймать грубые поломки: если из сорока не вернулся никто, дело не в статистике.

  • Когорты по неделям вместо дней, когда дневные слишком малы.
  • Абсолютные числа рядом с процентом — всегда.
  • Незрелые когорты исключены или помечены пустой ячейкой.
  • Сравнение только между когортами одинаковой зрелости.
  • Определение возврата названо: любое событие или целевое действие.

Мини-практика и следующий шаг

Начни с расчёта D1 для одной когорты, затем добавь D7 и разрез channel. После этого собери матрицу по возрасту пользователя и пометь незрелые ячейки как NULL. Сравни получившийся retention с общей активностью и воронкой: высокий первый возврат не гарантирует, что пользователь дошёл до ценности.

Продолжить чтение
Вся библиотека