SQL-запросы для продуктового аналитика

Схема данных

  • users (user_id, registered_at, country, platform)
  • events (user_id, event_name, event_time, properties)
  • ab_test_users (user_id, ab_group, test_id)

1. DAU за 30 дней

SELECT event_time::DATE AS day, COUNT(DISTINCT user_id) AS dau
FROM events
WHERE event_name = 'app_open'
  AND event_time >= CURRENT_DATE - INTERVAL '30 day'
GROUP BY 1 ORDER BY 1;

2. WAU по неделям

SELECT DATE_TRUNC('week', event_time) AS week,
    COUNT(DISTINCT user_id) AS wau
FROM events
WHERE event_name = 'app_open'
GROUP BY 1 ORDER BY 1;

3. Stickiness (DAU/MAU)

WITH daily AS (
    SELECT DISTINCT event_time::DATE AS day, user_id
    FROM events WHERE event_name = 'app_open'
),
dau AS (SELECT day, COUNT(DISTINCT user_id) AS dau FROM daily GROUP BY day),
mau AS (
    SELECT day,
        COUNT(DISTINCT user_id) OVER (
            ORDER BY day RANGE BETWEEN INTERVAL '27 day' PRECEDING AND CURRENT ROW
        ) AS mau
    FROM (SELECT DISTINCT day, user_id FROM daily) d
)
SELECT d.day, d.dau, m.mau, ROUND(d.dau * 1.0 / m.mau, 3) AS stickiness
FROM dau d JOIN mau m USING (day);

4. Retention D7

WITH cohort AS (
    SELECT DISTINCT user_id
    FROM events
    WHERE event_time::DATE = '2026-04-01'
),
d7 AS (
    SELECT DISTINCT user_id
    FROM events
    WHERE event_time::DATE = '2026-04-08'
)
SELECT COUNT(*) * 100.0 / (SELECT COUNT(*) FROM cohort) AS retention_d7
FROM cohort INTERSECT SELECT user_id FROM d7;

5. Воронка регистрации

SELECT
    COUNT(DISTINCT user_id) FILTER (WHERE event = 'landing_visit') AS visits,
    COUNT(DISTINCT user_id) FILTER (WHERE event = 'signup_start') AS starts,
    COUNT(DISTINCT user_id) FILTER (WHERE event = 'signup_complete') AS signups,
    COUNT(DISTINCT user_id) FILTER (WHERE event = 'email_confirm') AS confirmed
FROM events
WHERE event_time >= CURRENT_DATE - INTERVAL '30 day';

Если хочется сразу закрепить тему на практике — открой тренажёр в Telegram. 10 минут в день — и синтаксис в пальцах.

6. Activation rate (из регистрации в первое действие)

SELECT
    COUNT(DISTINCT user_id) FILTER (WHERE registered) AS regs,
    COUNT(DISTINCT user_id) FILTER (WHERE registered AND first_action) AS activated,
    ROUND(
        COUNT(DISTINCT user_id) FILTER (WHERE registered AND first_action) * 100.0 /
        NULLIF(COUNT(DISTINCT user_id) FILTER (WHERE registered), 0), 2
    ) AS activation_rate
FROM user_flags
WHERE registered_at >= CURRENT_DATE - INTERVAL '30 day';

7. Когортная retention таблица

WITH cohort AS (
    SELECT user_id, DATE_TRUNC('week', MIN(event_time))::DATE AS cohort_week
    FROM events GROUP BY user_id
),
activity AS (
    SELECT c.user_id, c.cohort_week,
        (DATE_TRUNC('week', e.event_time)::DATE - c.cohort_week) / 7 AS week_num
    FROM cohort c JOIN events e USING (user_id)
)
SELECT cohort_week, week_num, COUNT(DISTINCT user_id) AS active
FROM activity GROUP BY 1, 2 ORDER BY 1, 2;

8. Anti-funnel: где отваливаются пользователи

SELECT
    step,
    users,
    LAG(users) OVER (ORDER BY step_num) AS prev,
    LAG(users) OVER (ORDER BY step_num) - users AS dropoff,
    ROUND((LAG(users) OVER (ORDER BY step_num) - users) * 100.0 /
        LAG(users) OVER (ORDER BY step_num), 2) AS dropoff_pct
FROM (
    SELECT 1 AS step_num, 'signup' AS step,
        COUNT(DISTINCT user_id) FILTER (WHERE event = 'signup') AS users
    FROM events
    UNION ALL
    SELECT 2, 'activation',
        COUNT(DISTINCT user_id) FILTER (WHERE event = 'first_action')
    FROM events
    -- И так далее
) steps;

9. Session identification (30-min timeout)

WITH diffs AS (
    SELECT user_id, event_time,
        EXTRACT(EPOCH FROM (event_time -
            LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time)
        )) / 60 AS min_gap
    FROM events
)
SELECT user_id, event_time,
    SUM(CASE WHEN min_gap > 30 OR min_gap IS NULL THEN 1 ELSE 0 END)
        OVER (PARTITION BY user_id ORDER BY event_time) AS session_id
FROM diffs;

10. A/B-тест: конверсия по группам

SELECT a.ab_group,
    COUNT(DISTINCT a.user_id) AS users,
    COUNT(DISTINCT e.user_id) FILTER (WHERE e.event = 'purchase') AS converters,
    ROUND(
        COUNT(DISTINCT e.user_id) FILTER (WHERE e.event = 'purchase') * 100.0 /
        COUNT(DISTINCT a.user_id), 2
    ) AS conv_pct
FROM ab_test_users a
LEFT JOIN events e USING (user_id)
WHERE a.test_id = 'new_onboarding_v2'
GROUP BY a.ab_group;

11. Power users (топ 10% активных)

WITH activity AS (
    SELECT user_id, COUNT(*) AS event_count
    FROM events
    WHERE event_time >= CURRENT_DATE - INTERVAL '30 day'
    GROUP BY user_id
),
percentiles AS (
    SELECT user_id, event_count,
        PERCENT_RANK() OVER (ORDER BY event_count) AS prank
    FROM activity
)
SELECT user_id, event_count
FROM percentiles
WHERE prank >= 0.9;

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

12. Feature adoption

SELECT feature, COUNT(DISTINCT user_id) AS users,
    COUNT(DISTINCT user_id) * 100.0 / (SELECT COUNT(DISTINCT user_id) FROM events) AS adoption_pct
FROM events
WHERE event = 'feature_use'
GROUP BY feature
ORDER BY users DESC;

13. Session length distribution

WITH sessions AS (
    SELECT user_id, session_id,
        MAX(event_time) - MIN(event_time) AS duration
    FROM events_with_session
    GROUP BY user_id, session_id
)
SELECT
    AVG(EXTRACT(EPOCH FROM duration) / 60) AS avg_min,
    PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY duration) AS median
FROM sessions;

14. D1 retention по фичам онбординга

WITH users_by_step AS (
    SELECT user_id,
        MAX(CASE WHEN event = 'onboarding_step_3' THEN 1 ELSE 0 END) AS completed_onb
    FROM events WHERE registered_at >= CURRENT_DATE - INTERVAL '30 day'
    GROUP BY user_id
),
d1 AS (
    SELECT DISTINCT user_id
    FROM events
    WHERE event_time::DATE = registered_at::DATE + 1
)
SELECT
    u.completed_onb,
    COUNT(*) AS cohort,
    COUNT(d1.user_id) AS retained,
    ROUND(COUNT(d1.user_id) * 100.0 / COUNT(*), 2) AS d1_pct
FROM users_by_step u
LEFT JOIN d1 USING (user_id)
GROUP BY u.completed_onb;

15. Engagement score

WITH user_metrics AS (
    SELECT user_id,
        COUNT(DISTINCT event_time::DATE) AS active_days,
        COUNT(*) AS total_events,
        COUNT(DISTINCT event_name) AS unique_events
    FROM events
    WHERE event_time >= CURRENT_DATE - INTERVAL '30 day'
    GROUP BY user_id
)
SELECT user_id,
    (active_days * 3 + total_events * 0.1 + unique_events * 2) AS engagement_score
FROM user_metrics
ORDER BY engagement_score DESC
LIMIT 100;

Custom формула, зависит от продукта.

Читайте также

FAQ

Какие индексы нужны для продуктовой аналитики?

events(user_id, event_time, event_name) — покрывает 80% запросов.

PostgreSQL или ClickHouse?

Для продуктовой — ClickHouse. Миллиарды событий, агрегаты быстрые.

Как ускорить когортные запросы?

Pre-aggregate в materialized view или Data Mart, обновляйте по расписанию.

Когда оконная, когда JOIN?

Для расчётов «рядом» (сравнение с предыдущим) — оконная. Для обогащения — JOIN.