Как посчитать active days в SQL

Карьерник — квиз-тренажёр в Telegram с 1500+ вопросами для собесов аналитика. SQL, Python, A/B, метрики. Бесплатно.

Что такое active days

Active days — количество дней, в которые пользователь проявлял активность в продукте.

Подходит как метрика engagement: сколько из 28 возможных дней пользователь был с продуктом. Часто используется вместо stickiness для более богатого сигнала.

Схема данных

events (user_id, event_at)

1. Active days пользователя за период

SELECT
    user_id,
    COUNT(DISTINCT DATE(event_at)) AS active_days
FROM events
WHERE event_at >= '2026-04-01'
  AND event_at <  '2026-05-01'
GROUP BY user_id;

2. Распределение active days

WITH user_days AS (
    SELECT
        user_id,
        COUNT(DISTINCT DATE(event_at)) AS active_days
    FROM events
    WHERE event_at >= NOW() - INTERVAL '28 days'
    GROUP BY user_id
)
SELECT
    active_days,
    COUNT(*) AS users_cnt
FROM user_days
GROUP BY active_days
ORDER BY active_days;

Показывает, сколько пользователей активны 1, 2, 3, ... 28 дней из 28.

3. L28 метрика (power users)

Доля пользователей, активных в каждый день из последних 28:

WITH user_days AS (
    SELECT
        user_id,
        COUNT(DISTINCT DATE(event_at)) AS days_active_28
    FROM events
    WHERE event_at >= NOW() - INTERVAL '28 days'
    GROUP BY user_id
)
SELECT
    AVG(CASE WHEN days_active_28 >= 1  THEN 1.0 ELSE 0 END) AS l1_rate,
    AVG(CASE WHEN days_active_28 >= 7  THEN 1.0 ELSE 0 END) AS l7_rate,
    AVG(CASE WHEN days_active_28 >= 14 THEN 1.0 ELSE 0 END) AS l14_rate,
    AVG(CASE WHEN days_active_28 >= 21 THEN 1.0 ELSE 0 END) AS l21_rate,
    AVG(CASE WHEN days_active_28 >= 28 THEN 1.0 ELSE 0 END) AS l28_rate
FROM user_days;

4. Active days за последние 7 / 14 / 28 дней

SELECT
    user_id,
    COUNT(DISTINCT CASE WHEN event_at >= NOW() - INTERVAL '7 days'  THEN DATE(event_at) END) AS days_7,
    COUNT(DISTINCT CASE WHEN event_at >= NOW() - INTERVAL '14 days' THEN DATE(event_at) END) AS days_14,
    COUNT(DISTINCT CASE WHEN event_at >= NOW() - INTERVAL '28 days' THEN DATE(event_at) END) AS days_28
FROM events
WHERE event_at >= NOW() - INTERVAL '28 days'
GROUP BY user_id;

5. Active days по когортам

Как active days меняется с возрастом пользователя:

WITH cohorts AS (
    SELECT user_id, DATE_TRUNC('month', MIN(event_at)) AS cohort_month
    FROM events GROUP BY user_id
),
activity AS (
    SELECT
        user_id,
        DATE(event_at) AS active_date,
        DATE_TRUNC('month', event_at) AS activity_month
    FROM events
    GROUP BY user_id, DATE(event_at), DATE_TRUNC('month', event_at)
)
SELECT
    c.cohort_month,
    a.activity_month,
    COUNT(DISTINCT a.active_date) / COUNT(DISTINCT c.user_id) AS avg_active_days
FROM cohorts c
JOIN activity a ON a.user_id = c.user_id
GROUP BY c.cohort_month, a.activity_month
ORDER BY c.cohort_month, a.activity_month;

6. Самая длинная streak активности

Streak — последовательные дни активности.

WITH user_days AS (
    SELECT DISTINCT
        user_id,
        DATE(event_at) AS day
    FROM events
),
day_rn AS (
    SELECT
        user_id,
        day,
        day - ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY day) * INTERVAL '1 day' AS streak_group
    FROM user_days
),
streaks AS (
    SELECT
        user_id,
        streak_group,
        COUNT(*) AS streak_length,
        MIN(day) AS streak_start,
        MAX(day) AS streak_end
    FROM day_rn
    GROUP BY user_id, streak_group
)
SELECT
    user_id,
    MAX(streak_length) AS longest_streak
FROM streaks
GROUP BY user_id;

Идея: если дни идут подряд, то day - rn_days = одинаковое значение → группа.

7. Текущий streak (до сегодня)

WITH user_days AS (
    SELECT DISTINCT
        user_id,
        DATE(event_at) AS day
    FROM events
    WHERE event_at >= NOW() - INTERVAL '60 days'
),
recent AS (
    SELECT
        user_id,
        day,
        CURRENT_DATE - day AS days_ago
    FROM user_days
),
continuous AS (
    SELECT
        user_id,
        day,
        ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY day DESC) - 1 AS expected_days_ago,
        (CURRENT_DATE - day) AS actual_days_ago
    FROM recent
)
SELECT
    user_id,
    COUNT(*) AS current_streak
FROM continuous
WHERE expected_days_ago = actual_days_ago
GROUP BY user_id;

8. Average active days per user

WITH user_days AS (
    SELECT user_id, COUNT(DISTINCT DATE(event_at)) AS active_days
    FROM events
    WHERE event_at >= NOW() - INTERVAL '28 days'
    GROUP BY user_id
)
SELECT
    AVG(active_days) AS avg_active_days_per_user,
    PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY active_days) AS median
FROM user_days;

9. Segments by active days

Разделим пользователей на группы:

WITH user_days AS (
    SELECT user_id, COUNT(DISTINCT DATE(event_at)) AS active_days
    FROM events WHERE event_at >= NOW() - INTERVAL '28 days'
    GROUP BY user_id
)
SELECT
    CASE
        WHEN active_days >= 28 THEN 'super_user (L28)'
        WHEN active_days >= 21 THEN 'power_user'
        WHEN active_days >= 14 THEN 'regular'
        WHEN active_days >= 7  THEN 'casual'
        WHEN active_days >= 1  THEN 'occasional'
    END AS segment,
    COUNT(*) AS users_cnt
FROM user_days
GROUP BY 1;

10. Active days и monetization

Связь active days с тем, платит ли пользователь:

WITH user_activity AS (
    SELECT
        user_id,
        COUNT(DISTINCT DATE(event_at)) AS active_days
    FROM events WHERE event_at >= NOW() - INTERVAL '28 days'
    GROUP BY user_id
),
user_revenue AS (
    SELECT user_id, SUM(total) AS revenue
    FROM orders WHERE status = 'paid'
    GROUP BY user_id
)
SELECT
    ua.active_days,
    COUNT(*) AS users,
    AVG(COALESCE(ur.revenue, 0)) AS avg_revenue_per_user,
    AVG(CASE WHEN ur.revenue > 0 THEN 1.0 ELSE 0 END) AS paid_conversion
FROM user_activity ua
LEFT JOIN user_revenue ur ON ur.user_id = ua.user_id
GROUP BY ua.active_days
ORDER BY ua.active_days;

Классика: больше active days → выше conversion и revenue.

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

Ошибка 1. COUNT без DISTINCT

-- считает события, не дни
COUNT(DATE(event_at))

-- правильно
COUNT(DISTINCT DATE(event_at))

Ошибка 2. Таймзоны

-- если сервер UTC, а юзер в МСК — дни могут сдвинуться
COUNT(DISTINCT DATE(event_at))

-- правильно
COUNT(DISTINCT DATE(event_at AT TIME ZONE 'Europe/Moscow'))

Ошибка 3. Брать события без фильтра

Бот-активности / тестовые события искажают. Фильтруйте перед агрегацией.

Ошибка 4. Смешивать окна

28 дней, 4 недели, месяц — все разные. Документируйте, что берёте.

Связанные темы

FAQ

Active days или DAU — что смотреть?

DAU — общая активность. Active days per user — глубина вовлечения. Оба полезны.

L28 = 100% — это идеал?

Идеал недостижим в большинстве продуктов. L28 даже 10% — хорошо для большинства B2C.

Streak — это активность подряд?

Да. Каждый день без пропуска. После пропуска — счётчик обнуляется.

Как часто пересчитывать active days?

Ежедневно (batch) или по запросу. Для дашбордов — ежедневно через materialized view.


Тренируйте SQL — откройте тренажёр с 1500+ вопросами для собесов.