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.
- Показывать цифру без периода, знаменателя и определения события.
Что изучать дальше
После базовой выборки следующий уровень — научиться собирать реальные продуктовые расчёты. Сначала закрепи фильтрацию и уникальных пользователей на DAU, потом переходи к воронке, когортам, retention и деньгам. Оконные функции оставь на момент, когда уже уверенно понимаешь grain и агрегации.