SQL GROUP BY и COUNT: как считать пользователей по каналам и дням
Разбираем GROUP BY, COUNT и COUNT DISTINCT на задачах аналитика: пользователи по каналам, DAU по дням и фильтрация агрегатов через HAVING.
Содержание статьи
В первой SQL-выборке мы смотрели на отдельные строки. Но продуктовые вопросы почти всегда звучат как “сколько пользователей пришло из каждого канала”, “какой DAU был по дням” или “какие сегменты дают больше заказов”. Здесь в дело вступают GROUP BY и COUNT: они превращают длинный список строк в компактную сводку, с которой уже можно работать в отчёте.
Коротко
GROUP BY собирает строки в группы, а агрегатная функция считает или суммирует значение внутри каждой группы. Главное — заранее понять, какой должна быть одна строка результата: один канал, один день, одна когорта или одна связка “день × канал”.
GROUP BY channelвернёт по одной строке на канал.COUNT(*)считает строки, а не обязательно уникальных людей.COUNT(DISTINCT user_id)отвечает на вопрос про уникальных пользователей.- Все выбранные поля без агрегата должны попасть в
GROUP BY. HAVINGфильтрует уже собранные группы, аWHERE— исходные строки.
Что именно должна означать строка
До запроса сформулируй зерно результата. Если вопрос “сколько регистраций было по каналам”, одна строка должна означать один канал. Если вопрос “как менялся DAU”, одна строка должна означать один день. Если добавить в SELECT ещё и платформу, зерно изменится на “канал × платформа”, и строк станет больше — это не ошибка, если изменение было намеренным.
| Вопрос | Одна строка результата | Группировка |
|---|---|---|
| Сколько пользователей по каналам? | один канал | channel |
| Какой DAU по дням? | один календарный день | event_date |
| Какой DAU по каналам и дням? | день × канал | event_date, channel |
| Какие каналы дали минимум 100 пользователей? | один канал после фильтра | channel + HAVING |
GROUP BY на таблице users
В учебной таблице users одна строка соответствует одному пользователю, поэтому COUNT(*) здесь действительно считает пользователей. Для более явного запроса можно использовать COUNT(user_id): оба варианта дадут одинаковый результат, если user_id заполнен в каждой строке.
Результат имеет одно важное свойство: количество строк в нём равно количеству каналов, а не количеству пользователей. Это и есть смысл агрегации — много исходных строк превращаются в одну строку на группу.
select
channel,
count(*) as users_count
from users
group by channel
order by users_count desc, channel;COUNT(*) и COUNT(DISTINCT) — это не одно и то же
На таблице событий разница становится критичной. Один пользователь может открыть приложение несколько раз за день, поэтому COUNT(*) посчитает события, а COUNT(DISTINCT user_id) — людей. Выбирай функцию не по привычке, а по существительному в вопросе: “сколько событий” или “сколько пользователей”.
| Выражение | Смысл результата | Когда применять |
|---|---|---|
| COUNT(*) | число строк | события, заказы или платежи |
| COUNT(user_id) | число непустых user_id | если строки могут быть анонимными |
| COUNT(DISTINCT user_id) | число уникальных пользователей | DAU, MAU, buyers, активированные пользователи |
| SUM(amount) | сумма значений | выручка, скидка или количество единиц |
select
event_date,
count(*) as events_count,
count(distinct user_id) as active_users
from events
where event_name = 'core_action'
group by event_date
order by event_date;DAU по дням: агрегация продуктовой метрики
Для DAU сначала нужно договориться, какое событие считается активностью. В примере берём только core_action, потому что техническое открытие приложения может создавать шум. Зерно events — одно событие, а зерно результата — один день и число уникальных пользователей в этот день.
select
event_date,
count(distinct user_id) as dau
from events
where event_name = 'core_action'
and event_date >= date '2026-06-01'
and event_date < date '2026-07-01'
group by event_date
order by event_date;Если в данных нет событий в какой-то день, запрос не создаст строку с нулём автоматически. Для отчёта с непрерывной осью дат понадобится календарная таблица или generate_series, а не только GROUP BY по events.
WHERE и HAVING решают разные задачи
WHERE сокращает исходные строки до группировки. HAVING применяется после GROUP BY и может использовать агрегат, например оставить только каналы, где больше двух пользователей. Перепутать их легко: условие по channel обычно живёт в WHERE, а условие по COUNT(*) — в HAVING.
select
channel,
count(*) as users_count
from users
where signup_date < date '2026-07-01'
group by channel
having count(*) >= 2
order by users_count desc;WHEREуменьшает объём данных до агрегации.HAVINGфильтрует уже сформированные группы.- Фильтр по дате регистрации обычно относится к WHERE.
- Фильтр “оставь каналы с 100 пользователями” относится к HAVING.
Ошибки, из-за которых метрика меняет смысл
Агрегации часто выглядят правильно даже тогда, когда считают не ту сущность. Поэтому перед отправкой результата полезно сравнить несколько контрольных чисел: размер исходной таблицы, количество уникальных пользователей и сумму групп.
Для users сумма users_count по каналам должна совпадать с числом строк, которые прошли WHERE. Если не совпадает — ищи NULL, дубли или неверную границу периода.
- Добавить поле в SELECT и забыть добавить его в GROUP BY.
- Посчитать COUNT(*) в events и назвать результат DAU.
- Отфильтровать группы через WHERE с агрегатной функцией вместо HAVING.
- Смешать полный месяц с текущим неполным месяцем.
- Не заметить NULL-канал и потерять его из группировки или отчёта.
- Округлить значения слишком рано и получить расхождение между строками и итогом.
COUNT, COUNT DISTINCT и вес строки
COUNT(*) отвечает на вопрос о строках, а COUNT(user_id) — о непустых user_id. Ни один из них не означает число уникальных пользователей, если один человек может иметь несколько событий. Для DAU нужен count(distinct user_id) после фильтра active action и периода.
В одной сводке можно показать несколько числителей, чтобы разница была видна: all_events, events_with_user, unique_users. Это полезнее, чем сразу выбирать один вариант и скрывать качество идентификатора. Если anonymous users допускаются, вынеси их в отдельную логику identity stitching.
После группировки проверь сумму групп. Для взаимоисключающих каналов сумма пользователей может быть равна общей базе, а для событий она почти никогда не равна числу уникальных людей. Инвариант зависит от зерна, его нужно назвать до SQL.
select
event_date,
count(*) as events,
count(user_id) as identified_events,
count(distinct user_id) as active_users
from events
where event_name = 'core_action'
group by event_date
order by event_date;WHERE и HAVING меняют этап фильтрации
WHERE сокращает исходные строки до группировки. HAVING смотрит на уже рассчитанный агрегат. Если нужно посчитать DAU только для core_action, фильтр события должен быть в WHERE. Если нужно оставить дни, где DAU выше 100, это HAVING.
Не прячь фильтр периода в HAVING без причины. Так база сначала обработает лишние строки, а смысл запроса станет труднее читать. Порядок в тексте SQL — не точный план выполнения, но он хорошо показывает, на каком слое применяется правило.
Если фильтр относится к правой таблице после JOIN, вернись к правилу ON против WHERE. В продуктовых метриках неправильное расположение условия меняет знаменатель так же легко, как неправильный COUNT.
| Условие | Место | Пример |
|---|---|---|
| тип события | WHERE | event_name = 'core_action' |
| период | WHERE | event_date >= start |
| минимальный DAU | HAVING | count(distinct user_id) > 100 |
| сохранить пользователей без событий | ON | условие по events в LEFT JOIN |
Мини-практика и следующий шаг
В SQL-тренажёре начни с GROUP BY channel и COUNT(*), затем попробуй добавить сортировку по количеству пользователей. После этого измени запрос для events: сравни число core actions и число уникальных пользователей по дням. Разница между двумя столбцами — отличный способ увидеть, почему DAU нельзя считать обычным COUNT(*).
Сначала назови зерно результата
До GROUP BY закончи фразу: “одна строка результата — это один…”. Для пользователей по каналам ответ — один канал. Для DAU — один календарный день. Для DAU по каналам — день и канал одновременно. Если добавить поле в SELECT, но не изменить описание зерна, легко получить метрику, которую команда читает неправильно.
Группировка не делает данные уникальными сама по себе. Она собирает строки в группы по указанным полям. Две одинаковые даты, но разные каналы становятся двумя строками. Если нужна одна строка на день, channel нельзя оставлять в SELECT без отдельного решения: убрать его, агрегировать или изменить grain.
| GROUP BY | Одна строка результата |
|---|---|
| channel | один канал |
| event_date | один день |
| event_date, channel | один день и один канал |
| user_id, event_date | один пользователь и один день |
NULL в группировке и COALESCE
В GROUP BY все строки с NULL обычно попадают в одну группу. Это технически корректно, но подпись в отчёте может быть непонятной. Используй coalesce(channel, 'unknown'), если бизнесу нужна явная категория, и отдельно покажи её размер. Не подменяй NULL пустой строкой без договорённости: причина отсутствия значения может быть важна для data quality.
Для числовых агрегатов COALESCE тоже меняет читаемость. sum(amount) по группе без строк может вернуть NULL, а coalesce(sum(amount), 0) — ноль. Реши, означает ли отсутствие строк нулевую активность или недостаток данных, прежде чем форматировать результат для BI.
select
coalesce(channel, 'unknown') as channel,
count(*) as users_count
from users
group by coalesce(channel, 'unknown')
order by users_count desc;Нулевая метрика может означать, что событий не было. Пропавшая строка может означать, что календарь или источник не загрузились. Не смешивай их без проверки.
Непрерывный календарь для DAU
GROUP BY по events возвращает только дни, когда нашлась хотя бы одна подходящая строка. Если в середине периода не было core action или загрузка сломалась, день исчезнет из результата. Для графика это может выглядеть как пропуск, а не как ноль. Если нужны все календарные дни, сначала создай календарь, затем LEFT JOIN агрегированный DAU.
При этом ноль и NULL нужно интерпретировать аккуратно. Календарь с нулём говорит “в источнике нет подходящих событий”. Он не говорит, что данные полностью загрузились. Добавь контроль свежести и объёма событий, если график используется для оперативного решения.
with daily_dau as (
select
event_date,
count(distinct user_id) as dau
from events
where event_name = 'core_action'
group by event_date
)
select
calendar_date::date as event_date,
coalesce(d.dau, 0) as dau
from generate_series(
date '2026-06-01',
date '2026-06-30',
interval '1 day'
) as calendar_date
left join daily_dau as d
on d.event_date = calendar_date::date
order by event_date;Агрегаты можно проверять несколькими способами
Для простой метрики сделай две независимые сверки. DAU по дням можно сравнить с числом уникальных пар user_id и event_date. Пользователей по каналам — с общим количеством строк users после того же WHERE. Выручку по категориям — с суммой исходных платежей. Если два метода расходятся, не выбирай тот, который красивее выглядит.
Контрольная сверка особенно нужна после JOIN, фильтрации событий и перехода на новую витрину. Запрос может быть синтаксически правильным, но поменять знаменатель. Записывай ожидаемый инвариант рядом с SQL: это простая документация для будущего изменения.
- Сумма непересекающихся групп равна общей базе.
- COUNT(DISTINCT user_id) не превышает COUNT(*).
- DAU не превышает число пользователей в доступном периоде.
- После фильтра события размер базы уменьшается объяснимо.
- Пустой день отличён от отсутствующей строки.
Мини-кейс: DAU вырос из-за неверного COUNT
Команда видит рост “DAU” на 35% и связывает его с новым релизом. В запросе используется count(*) по events, а один пользователь может отправить несколько core_action за день. После замены на count(distinct user_id) рост исчезает: релиз увеличил число действий на пользователя, но не число активных людей.
Это не бесполезный результат. Он меняет вопрос: возможно, пользователи стали глубже пользоваться функцией, а возможно, событие начало дублироваться. Теперь нужно посмотреть actions per active user и качество трекинга, а не повторять спор о DAU.
Материалы по теме

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

SQL для аналитика: как считать продуктовые метрики запросами
С чего начать в SQL, как читать grain, фильтровать события, считать уникальных пользователей, соединять таблицы и превращать результат в вывод по продукту.

DAU: как считать активных пользователей так, чтобы метрика не врала
Разбираем DAU без самообмана: что считать активностью, как разделять новых и вернувшихся пользователей и почему рост метрики ещё не доказывает здоровье продукта.