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

Проверь себя · 1/3разбор после ответа
Как GROUP BY обрабатывает значения NULL в столбце группировки?

Что такое MAU

MAU (Monthly Active Users) — число уникальных пользователей, совершивших целевое действие хотя бы раз за месяц.

«Целевое действие» зависит от продукта: запуск приложения, просмотр контента, действительно значимое событие (не просто пинг сервера). На собесе стоит явно уточнять определение активности перед тем, как писать SQL.

Два способа считать MAU

Calendar MAU

Самый простой — по календарному месяцу:

SELECT
    DATE_TRUNC('month', created_at) AS month,
    COUNT(DISTINCT user_id)          AS mau
FROM events
WHERE event_name = 'app_open'
GROUP BY 1
ORDER BY 1;

Минус очевидный: в феврале 28 дней, в марте — 31, MAU напрямую несравнимы.

Rolling MAU (28-day)

Каждый день считаем уникальных пользователей за предыдущие 28 дней. В Postgres COUNT(DISTINCT ...) нельзя использовать как оконную функцию, поэтому делаем через подзапрос:

WITH days AS (
    SELECT DISTINCT created_at::DATE AS day FROM events
)
SELECT
    d.day,
    (
        SELECT COUNT(DISTINCT e.user_id)
        FROM events e
        WHERE e.event_name = 'app_open'
          AND e.created_at >= d.day - INTERVAL '27 day'
          AND e.created_at <  d.day + INTERVAL '1 day'
    ) AS mau_28d
FROM days d
ORDER BY d.day;

28 дней — это ровно 4 недели, они убирают недельные паттерны и делают значения сравнимыми. Для продовых дашбордов такой расчёт обычно кэшируется в daily-snapshot-таблице, чтобы не гонять тяжёлый запрос каждый раз.

MAU за последние 30 дней

Короткая форма для ad-hoc:

SELECT COUNT(DISTINCT user_id) AS mau
FROM events
WHERE event_name = 'app_open'
  AND created_at >= CURRENT_DATE - INTERVAL '30 day';

MAU в разрезе

По платформе

SELECT
    platform,
    COUNT(DISTINCT user_id) AS mau
FROM events
WHERE event_name = 'app_open'
  AND created_at >= CURRENT_DATE - INTERVAL '30 day'
GROUP BY platform;

По каналу привлечения

SELECT
    u.channel,
    COUNT(DISTINCT e.user_id) AS mau
FROM events e
JOIN users u USING (user_id)
WHERE e.event_name = 'app_open'
  AND e.created_at >= CURRENT_DATE - INTERVAL '30 day'
GROUP BY u.channel
ORDER BY mau DESC;

DAU / MAU (stickiness)

Показывает долю MAU, которую мы видим в среднем за день:

WITH daily AS (
    SELECT
        created_at::DATE        AS day,
        COUNT(DISTINCT user_id) AS dau
    FROM events
    WHERE event_name = 'app_open'
    GROUP BY 1
),
rolling_mau AS (
    SELECT
        d.day,
        (
            SELECT COUNT(DISTINCT e.user_id)
            FROM events e
            WHERE e.event_name = 'app_open'
              AND e.created_at >= d.day - INTERVAL '27 day'
              AND e.created_at <  d.day + INTERVAL '1 day'
        ) AS mau_28d
    FROM daily d
)
SELECT
    d.day,
    d.dau,
    m.mau_28d,
    ROUND(d.dau::NUMERIC / NULLIF(m.mau_28d, 0), 3) AS stickiness
FROM daily        d
JOIN rolling_mau  m USING (day)
ORDER BY d.day;

Ориентиры:

  • 50% — daily-продукт (мессенджер, основной feed);

  • 20–40% — регулярный (соцсеть, e-commerce);
  • < 20% — редкий (страховка, travel, госуслуги).
Закрепи формулу MAU в Карьернике
Запомнить надолго — 5 коротких сессий с задачами на эту тему. Бесплатно
Тренировать MAU в Telegram

New vs returning MAU

WITH user_first AS (
    SELECT user_id, MIN(created_at)::DATE AS first_seen
    FROM events
    GROUP BY user_id
),
last_30 AS (
    SELECT DISTINCT user_id
    FROM events
    WHERE event_name = 'app_open'
      AND created_at >= CURRENT_DATE - INTERVAL '30 day'
)
SELECT
    COUNT(*) FILTER (WHERE first_seen >= CURRENT_DATE - INTERVAL '30 day') AS new_mau,
    COUNT(*) FILTER (WHERE first_seen <  CURRENT_DATE - INTERVAL '30 day') AS returning_mau
FROM last_30
JOIN user_first USING (user_id);

Это полезный разрез: если new/returning соотношение резко меняется, тренд в MAU объясняется разным образом — либо приходят новые, либо возвращаются старые.

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

1. Month-to-date MAU вместо rolling

-- Month-to-date MAU: до конца месяца цифра заниженная
WHERE DATE_TRUNC('month', created_at) = DATE_TRUNC('month', CURRENT_DATE)

-- Rolling 30-day — сравнимое значение каждый день
WHERE created_at >= CURRENT_DATE - INTERVAL '30 day'

2. Разные определения «active»

В одной компании бекенд может считать активностью открытие приложения, маркетинг — любое событие, а продукт — досмотр контента. MAU в трёх отчётах расходится, начинаются споры «у кого правильно». Определение метрики должно быть одно — и зафиксировано в документации.

3. Боты и тестовые аккаунты

Без фильтрации цифра раздута, stickiness кажется лучше, чем есть.

4. COUNT(DISTINCT ...) OVER

В большинстве СУБД это не работает. Для скользящего MAU используем подзапрос или LATERAL, как в шаблонах выше.

5. Отсутствие фильтра по событию

COUNT(DISTINCT user_id) без фильтра поднимает в MAU всех, у кого был любой event — включая технические пинги.

MAU vs WAU vs DAU

  • DAU — за день.
  • WAU — за неделю (7 дней).
  • MAU — за месяц (28–30 дней).

Для B2B-продуктов WAU часто информативнее MAU: рабочая аудитория естественно приходит в будни. Для consumer-продуктов MAU — стандартная ориентировочная метрика.

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

FAQ

Calendar или rolling MAU?

Для мониторинга — rolling (28 или 30 дней), потому что значения сравнимы каждый день. Для отчётности по финансам и для board-deck'ов обычно удобнее calendar.

Почему 28 дней, а не 30?

28 = 4 × 7. Такое окно убирает разницу в количестве выходных дней между месяцами и делает MAU ровно сравнимым во времени.

MAU выше DAU в 5 раз — это нормально?

Зависит от продукта. Для ежедневных продуктов MAU / DAU < 2,5x (stickiness > 40%). Для редких использований соотношение может быть в 10 раз и больше.

MAU падает — что делать?

Разделить на new и returning. Если падают новые — смотрите каналы привлечения и конверсию в регистрацию. Если падают возвращающиеся — смотрите retention, работу пушей, product issues после релизов.