SQL для аналитика: как считать продуктовые метрики запросами
С чего начать в SQL, как читать grain, фильтровать события, считать уникальных пользователей, соединять таблицы и превращать результат в вывод по продукту.
Содержание статьи
Запрос от команды обычно звучит не как упражнение по SQL. “Покажи DAU по каналам”, “найди пользователей, которые не дошли до оплаты”, “почему retention просел в июне” — это продуктовые вопросы. SQL нужен, чтобы превратить их в проверяемый расчёт, но хороший аналитик начинает не с клавиатуры, а с определения того, какие строки он собирается считать.
Коротко
SQL для аналитика — это не набор команд, которые нужно запомнить отдельно. Это последовательность решений: определить единицу анализа, выбрать таблицу и grain, отфильтровать нужные строки, агрегировать их и проверить, что результат отвечает на исходный вопрос.
- Сначала назови объект подсчёта: пользователь, аккаунт, событие, заказ или сессия.
- Проверь grain таблицы: одна строка может быть пользователем, событием или платежом.
- Используй
count(distinct user_id), когда вопрос про уникальных людей. - После JOIN сравни количество строк и количество уникальных пользователей.
- Заканчивай запрос не цифрой, а следующим продуктовым вопросом.
Из вопроса в запрос
Перед SQL полезно переписать задачу одним предложением. Например: “Сколько уникальных пользователей сделали core action в каждый календарный день за последние 30 полных дней?” В этой фразе уже есть метрика, единица, событие, период и границы окна.
Если такой формулировки нет, запрос может быть технически правильным, но аналитически бесполезным. Он посчитает что-то из базы, а команда всё равно не поймёт, какое решение принимать.
| Вопрос | Что зафиксировать |
|---|---|
| Что считаем? | user_id, account_id, event_id, order_id или session_id |
| Где лежит факт? | users, events, payments, orders или отдельный view |
| Что значит активность? | конкретный core event, а не любое техническое событие |
| За какой период? | полные дни, недели, месяцы и продуктовая таймзона |
| Как проверим? | контрольный сегмент, размер базы и пример строк |
Начни с grain таблицы
Grain — это смысл одной строки. В users одна строка обычно описывает пользователя, в events — одно событие, в payments — одну денежную операцию. Если соединить events и payments по user_id, один пользователь может дать десятки строк. Это нормально для промежуточного результата, но опасно, если потом считать строки как пользователей.
Простейшая проверка: до каждого JOIN посмотри count(*), count(distinct user_id) и несколько строк вручную. Разница между ними часто сразу показывает, где в расчёте появилась мультипликация.
| Таблица | Одна строка | Что считаем |
|---|---|---|
| users | один пользователь | регистрация, канал, платформа, тариф |
| events | одно событие | активность, шаг воронки, core action |
| payments | один платёж или возврат | выручка, payer, ARPU, LTV |
SELECT, WHERE и GROUP BY на примере DAU
Первый полезный запрос можно собрать из трёх частей. SELECT выбирает дату и метрику, WHERE оставляет нужные события и период, GROUP BY собирает строки по дню. Важно считать уникальных пользователей: один человек может выполнить core action несколько раз.
select
date_trunc('day', occurred_at)::date as day,
count(distinct user_id) as dau
from events
where event_name in ('report_viewed', 'report_created')
and occurred_at >= current_date - interval '30 days'
and occurred_at < current_date
group by 1
order by 1;COUNT DISTINCT защищает от лишних событий
Если считать count(*), получится число событий, а не DAU. Такой показатель может вырасти потому, что активные пользователи стали кликать чаще, хотя сам охват не изменился. Для вопроса “сколько людей” нужен count(distinct user_id), а для вопроса “сколько действий” — обычный count(*).
Это не значит, что distinct нужно добавлять в каждый запрос без размышлений. Он меняет смысл метрики и может скрыть дубли идентичности. Если часть людей анонимна, сначала нужно договориться, как склеиваются anonymous_id и user_id.
| Выражение | Что отвечает |
|---|---|
| count(*) | Сколько строк или событий попало в выборку? |
| count(distinct user_id) | Сколько уникальных пользователей сделали действие? |
| count(distinct session_id) | Сколько сессий содержали действие? |
| count(*) filter (where ...) | Сколько строк прошло отдельное условие? |
JOIN: место, где метрика часто ломается
JOIN нужен, когда событие нужно обогатить свойствами пользователя или связать с оплатой. Например, events хранит действие, а users — канал. Но после соединения нужно помнить о grain: у одного пользователя может быть много событий, поэтому count(*) после JOIN не станет количеством пользователей.
Для обычной сегментации можно присоединить users к событиям и оставить count(distinct e.user_id). Если соединяются две таблицы с несколькими строками на пользователя, сначала агрегируй каждую сторону до нужного grain, а уже потом соединяй.
select
u.channel,
count(distinct e.user_id) as active_users
from events e
join users u on u.user_id = e.user_id
where e.event_name in ('report_viewed', 'report_created')
and e.occurred_at >= current_date - interval '30 days'
and e.occurred_at < current_date
group by 1
order by active_users desc;CTE превращает длинный запрос в последовательность шагов
Когда в запросе появляется несколько смысловых действий, CTE помогает назвать их. Например, сначала собрать активных пользователей, потом присоединить их канал и только затем получить итоговую таблицу. Это не делает расчёт автоматически правильным, но облегчает проверку каждого промежуточного результата.
Каждый CTE должен отвечать на один понятный вопрос. Если блок называется active_users, в нём не должно быть логики оплаты, retention и рекламных расходов одновременно.
with active_users as (
select distinct user_id
from events
where event_name in ('report_viewed', 'report_created')
and occurred_at >= current_date - interval '30 days'
and occurred_at < current_date
),
active_by_channel as (
select u.channel, count(*) as active_users
from active_users a
join users u using (user_id)
group by 1
)
select *
from active_by_channel
order by active_users desc;SQL-результат ещё не является выводом
Запрос сообщает, что paid social дал 42% активных пользователей. Но без сравнения с размером канала, retention и качеством core action это только описание объёма. Аналитический вывод появляется, когда результат связывается с контекстом и следующим решением.
Полезный формат финального абзаца: “что изменилось → где именно → какая гипотеза → что проверяем дальше”. Такой формат одинаково работает для DAU, воронки, retention и монетизации.
| Результат | Следующий вопрос |
|---|---|
| DAU снизился | Падение пришло из новых или вернувшихся пользователей? |
| Канал дал больше активных | Как изменились retention и core action rate этого канала? |
| После JOIN стало больше строк | Это реальные события или мультипликация one-to-many? |
| Выручка выросла | Изменились payer conversion, ARPPU или состав пользователей? |
Частые ошибки в первых запросах
Большинство проблем в начале связано не с синтаксисом. Запрос запускается, но считает не ту сущность, смешивает незрелые периоды или использует техническое событие вместо действия, которое несёт ценность.
- Считать строки events как пользователей.
- Соединять две one-to-many таблицы и не проверять размер результата.
- Использовать
select *в итоговом отчёте вместо явного списка полей. - Сравнивать текущий неполный день с полными историческими днями.
- Не фиксировать таймзону и границу календарного дня.
- Считать любое открытие приложения meaningful activity.
- Показывать цифру без периода, знаменателя и определения события.
Проверка SQL-метрики перед дашбордом
Перед публикацией запроса пройди короткий review. Назови зерно входа и выхода, период, timezone, событие, знаменатель и исключения. Затем сравни результат с независимым расчётом на маленьком срезе. Если запрос использует JOIN, проверь строки до и после соединения; если ratio — отдельно покажи числитель и знаменатель.
Попроси коллегу прочитать не только финальную цифру, но и первые строки промежуточного результата. Хороший review ловит не синтаксическую ошибку, а подмену сущности: события вместо пользователей, текущий день вместо полного или технический app_open вместо core action.
Сохрани запрос вместе с описанием метрики и датой изменения. SQL живёт дольше ноутбука, поэтому отсутствие контекста быстро превращает рабочий отчёт в набор непонятных фильтров.
| Пункт | Контрольный вопрос |
|---|---|
| grain | что означает одна строка результата? |
| period | обе границы заданы явно и период полный? |
| identity | считаем user_id, account_id или события? |
| join | не размножились ли факты? |
| interpretation | какое решение меняет цифра? |
Сквозной пример: недельная сводка продукта
Соберём теперь всё вместе на учебной базе «Маяка» — той же, что в SQL-тренажёре. Первая неделя июня, три таблицы, восемнадцать зарегистрированных пользователей. Менеджер просит «цифры по продукту за неделю», и под этим обычно понимают пять метрик: регистрации, активные пользователи, активация, платящие и выручка.
Маленький масштаб здесь помогает. Каждое число можно пересчитать вручную, а значит любое расхождение — это ошибка запроса, а не «особенности данных». На рабочих объёмах приём тот же, только сверяться придётся с независимым источником вроде биллинга.
| Метрика | Значение | Как считается |
|---|---|---|
| Регистрации | 18 | строк в users за период |
| DAU по дням | 3 · 5 · 6 · 6 · 7 · 7 · 4 | уникальные user_id с событием в этот день |
| Активация | 10 из 18 (55,6%) | создали workspace хотя бы раз |
| Плательщики | 8 из 18 (44,4%) | уникальные user_id в payments |
| Выручка | 232 | сумма amount в payments |
Пять метрик — пять разных знаменателей
Соблазн собрать всё одним запросом велик, но метрики живут на разном зерне. Регистрации считаются по пользователям, DAU — по парам «пользователь + день», выручка — по платежам. Смешать их в одном GROUP BY — прямой путь к числу, которое невозможно объяснить.
Практичнее собрать по одному запросу на зерно и только потом сводить результаты. Ниже — расчёт активности по дням: обратите внимание, что здесь считаются не события, а люди, поэтому пользователь с пятью открытиями приложения даёт единицу, а не пятёрку.
Числа в этой сводке не обязаны складываться в аккуратную воронку. Отчёт создали шесть пользователей, а заплатили восемь — среди плательщиков есть двое, которые до отчёта не дошли. Это не ошибка расчёта, а факт о продукте: оплата не стоит строго за тем шагом, который команда считает главным.
Ряд активности тоже стоит читать осторожно. За неделю DAU идёт 3, 5, 6, 6, 7, 7 и затем 4 — и последнее число само по себе выглядит как обвал на 43%. На деле база собрана по 7 июня включительно, а рост первых дней объясняется тем, что продукт только набирал аудиторию: каждый день добавлялись новые регистрации. Ни одно из этих чисел не является продуктовым выводом без ответа на два вопроса: полон ли последний день и сравниваем ли мы дни с одинаковым составом аудитории.
SELECT
CAST(event_time AS DATE) AS activity_date,
count(*) AS events_total,
count(DISTINCT user_id) AS dau
FROM events
WHERE event_time >= TIMESTAMP '2026-06-01 00:00:00'
AND event_time < TIMESTAMP '2026-06-08 00:00:00'
GROUP BY activity_date
ORDER BY activity_date;Одна витрина на пользователя вместо пяти отчётов
Когда метрик становится больше трёх, отдельные запросы начинают расходиться: в одном период по дате регистрации, в другом по дате события, в третьем забыли исключить тестовые аккаунты. Надёжнее собрать промежуточный слой «одна строка — один пользователь», где рядом лежат все его факты, и считать метрики уже из него.
Такой слой решает сразу три задачи. Период и правила отбора задаются один раз. Любую метрику можно разложить по каналу, стране или дате регистрации, ничего не переписывая. И появляется место для проверок: сумма выручки из витрины обязана совпадать с суммой в payments, а число строк — с числом пользователей.
WITH user_facts AS ( -- зерно: один пользователь
SELECT
u.user_id,
u.channel,
u.signup_date,
max(CASE WHEN e.event_name = 'workspace_created' THEN 1 ELSE 0 END) AS activated,
coalesce(p.revenue, 0) AS revenue
FROM users u
LEFT JOIN events e ON e.user_id = u.user_id
LEFT JOIN (
SELECT user_id, sum(amount) AS revenue
FROM payments GROUP BY user_id
) p ON p.user_id = u.user_id
GROUP BY u.user_id, u.channel, u.signup_date, p.revenue
)
SELECT
channel,
count(*) AS signups,
sum(activated) AS activated_users,
count(*) FILTER (WHERE revenue > 0) AS payers,
sum(revenue) AS revenue,
round(100.0 * sum(activated) / count(*), 1) AS activation_rate
FROM user_facts
GROUP BY channel
ORDER BY revenue DESC;Метрика полезнее, когда её можно разложить
Одно число редко подсказывает действие. «Выручка 232» не говорит, что делать, а вот та же выручка, разложенная на плательщиков и средний чек, уже указывает направление: работать над конверсией в оплату или над тарифом.
Правило простое: любую денежную метрику раскладывайте на количество и величину, а любую долю показывайте вместе с числителем и знаменателем. На учебной базе выручка распадается на восемь плательщиков и средний чек 29 — и сразу видно, что при восемнадцати регистрациях запас роста лежит в конверсии, а не в цене.
Тот же приём работает с падениями. Если выручка снизилась, первый вопрос — это меньше плательщиков или меньше средний чек. Ответ на него меняет, кто пойдёт разбираться: продуктовая команда или те, кто отвечает за тарифы.
SELECT
count(DISTINCT user_id) AS payers,
count(*) AS payments_count,
sum(amount) AS revenue,
round(sum(amount) / count(DISTINCT user_id), 2) AS revenue_per_payer,
round(sum(amount) / count(*), 2) AS avg_payment
FROM payments
WHERE paid_at >= DATE '2026-06-01'
AND paid_at < DATE '2026-06-15';Три подмены, из-за которых метрика тихо врёт
Ошибки в продуктовых метриках редко выглядят как ошибки. Запрос выполняется, число получается, график рисуется — и только через месяц выясняется, что считали не то.
Первая подмена — события вместо людей. count(*) по таблице событий отвечает на вопрос «сколько действий», а не «сколько пользователей». Разница растёт вместе с вовлечённостью: чем активнее аудитория, тем сильнее расходятся два числа, и тем убедительнее выглядит рост, которого нет.
Вторая — не тот период. У пользователя есть дата регистрации, у события — дата действия, у платежа — дата оплаты. «Пользователи за июнь» может означать зарегистрировавшихся в июне, активных в июне или заплативших в июне: три разных множества, которые почти никогда не совпадают. Формулировка «за июнь» без указания поля — источник половины споров на продуктовых встречах.
Третья — незакрытый период и мусорные аккаунты. Последний день почти всегда неполный, а внутренние и тестовые пользователи живут в тех же таблицах, что и настоящие. На маленьком продукте десяток тестовых аккаунтов способен изменить конверсию на проценты.
| Подмена | Симптом | Проверка |
|---|---|---|
| События вместо людей | метрика растёт быстрее аудитории | сравнить count(*) и count(DISTINCT user_id) |
| Не то поле даты | числа не сходятся с соседним отчётом | назвать поле периода вслух: signup_date или event_time |
| Неполный последний день | падение в конце графика | посмотреть максимальное время события в периоде |
| Тестовые аккаунты | странные значения у отдельных пользователей | исключить внутренние домены и служебные id |
| Дубли событий | активность выше, чем возможна физически | проверить повторы по user_id + событие + время |
Определение метрики стоит записать один раз
SQL — это исполняемая версия определения, но само определение живёт вне кода. Если оно не записано, каждый аналитик восстанавливает его заново, и через полгода в компании существует три «активации», которые не сходятся между собой.
Рабочий минимум занимает пять строк: что считаем (единица), за какой период и по какому полю, кого исключаем, каким событием подтверждается факт и что считается знаменателем. Такое описание помещается рядом с запросом в комментарии и переживает смену дашборда.
Отдельно стоит фиксировать момент изменения определения. Если вчера активацией считалось открытие приложения, а сегодня — создание рабочего пространства, ряд метрики нельзя сравнивать через эту границу. Пометка на графике в день смены правила стоит дешевле, чем разбор «почему в марте всё упало» через год.
- Единица счёта: пользователь, сессия, событие или платёж.
- Период и поле, по которому он берётся.
- Кто исключён: тестовые, внутренние, боты.
- Событие-подтверждение факта и окно, в котором оно засчитывается.
- Знаменатель: от кого считается доля.
- Дата последнего изменения определения.
Что сказать рядом с числами
Готовая сводка — половина работы. Вторая половина в том, чтобы честно назвать её границы, иначе метрику начнут использовать не так, как она посчитана.
Минимум, который стоит приложить: период и по какому полю он берётся, что считается активным действием, исключены ли тестовые аккаунты и насколько велика выборка. На учебной базе последний пункт решающий: 55,6% активации на восемнадцати пользователях — это десять человек, и один новый активированный сдвинет процент почти на шесть пунктов. Показывать такой процент без числителя и знаменателя — значит приглашать команду принять решение по шуму.
И отдельно — про свежесть. Последний день периода почти всегда неполный: данные ещё догружаются. Его либо исключают из сравнения, либо помечают явно, но никогда не подают как падение продукта.
От запроса к тренажёру и рабочему навыку
Лучший способ закрепить SQL — изменить один запрос и заранее предсказать, что произойдёт. Убери distinct и объясни рост числа строк, перенеси условие из ON в WHERE и найди потерянных пользователей, замени полный месяц на текущий и отметь несопоставимость. Такие эксперименты создают понимание, а не только память о синтаксисе.
В тренажёре можно идти от простой таблицы users к events, JOIN, CTE, воронкам и когортам. Для каждой задачи сначала напиши словами, что должна означать строка результата, затем собери запрос и проверь его на учебной схеме. Ошибка становится частью обучения, если после неё ты формулируешь правило.
В работе тот же цикл выглядит так: вопрос команды → определение → SQL → контрольные числа → график → решение. SQL — середина этого процесса, а не финальный продукт. Чем лучше связаны эти шаги, тем меньше риск отправить в дашборд красивую, но пустую метрику.
- Изменить один фильтр и предсказать эффект.
- Проверить запрос на маленьком срезе.
- Сравнить SQL-результат с независимым числом.
- Описать зерно и знаменатель рядом с графиком.
- Закрепить навык в SQL-тренажёре.
Что изучать дальше
После базовой выборки следующий уровень — научиться собирать реальные продуктовые расчёты. Сначала закрепи фильтрацию и уникальных пользователей на DAU, потом переходи к воронке, когортам, retention и деньгам. Оконные функции оставь на момент, когда уже уверенно понимаешь grain и агрегации.
Материалы по теме

SQL CTE: как разделить сложный запрос на понятные шаги
Разбираем WITH и CTE на задачах аналитика: как сначала собрать активных пользователей, затем присоединить сегменты и проверить каждый этап расчёта.

SQL GROUP BY и COUNT: как считать пользователей по каналам и дням
Разбираем GROUP BY, COUNT и COUNT DISTINCT на задачах аналитика: пользователи по каналам, DAU по дням и фильтрация агрегатов через HAVING.

DAU/MAU и stickiness: когда простая метрика начинает врать
Как читать DAU/MAU, WAU/MAU и stickiness без самообмана: когда метрика показывает привычку, а когда скрывает сезонность, платный трафик или редкий сценарий продукта.