Все материалы
Продуктовая аналитикагайдсредний

Агрегация в SQL: COUNT, SUM, GROUP BY, HAVING и условные метрики

Большой разбор агрегации в SQL для аналитика: чем COUNT(*) отличается от COUNT(column), когда нужен DISTINCT, а когда GROUP BY, почему фильтр по агрегату уходит в HAVING и как считать несколько метрик одним запросом через CASE и FILTER.

КПКейсПрактика23 июля 2026 г.25 мин
Содержание статьи

В отчёте «заказы по каналам» у paid_search стояло число 100, и в сводке для руководителя его назвали «100 заказов». Заказов было 64. Запрос собирал пользователей вместе с их заказами через LEFT JOIN: у 36 пользователей заказов нет, и в их строках order_id пустой. count(*) посчитал строки, включая пустые; count(order_id) — только заполненные. Покупателей при этом 51, потому что часть людей заказывала дважды. Три разных числа из одного запроса, и ни одной синтаксической ошибки. Вся разница в том, что никто не сказал вслух, что такое одна строка в этом наборе.

Коротко

Агрегат сжимает группу строк в одно значение. Пока не названо, что такое одна строка до группировки, любое такое значение можно прочитать как минимум двумя способами — и отчёт будет выглядеть одинаково правильным при обоих чтениях. Семь частых вопросов про агрегацию ниже — это один вопрос про зерно, заданный с разных сторон.

  • count(*) считает строки, count(column) — непустые значения, count(distinct column) — различные непустые значения. На одном наборе это три разных числа.
  • NULL выпадает из всех агрегатов кроме count(*), поэтому у avg знаменатель меньше, чем кажется.
  • avg(amount) по заказам и средний доход на клиента — разные метрики с разными знаменателями; одна не приближает другую.
  • distinct даёт уникальные комбинации выведенных колонок, group by даёт группы, к которым можно прицепить агрегат.
  • WHERE фильтрует строки до группировки и не видит алиасов из SELECT; HAVING фильтрует уже собранные группы и умеет сравнивать агрегаты.
  • CASE и FILTER считают несколько метрик за один проход по данным — это и единственный честный способ сохранить общий знаменатель.
  • Раскладка метрик по колонкам — не отдельная операция PIVOT, а та же условная агрегация, записанная шире.

Сначала назови, что такое одна строка, потом пиши агрегат

Зерно — это ответ на вопрос «что описывает одна строка». У таблицы заказов зерно обычно «один заказ». После join с позициями заказа зерно становится «одна позиция в заказе». После left join со справочником пользователей появляется третий вариант: «один пользователь, а если у него есть заказы — то один его заказ». В трёх случаях count(*) вернёт три разных числа, и все три будут правдой про свой набор.

Полезная привычка: перед написанием запроса записать зерно результата одной фразой. «Одна строка — канал за месяц». «Одна строка — пользователь». «Одна строка — заказ». Фраза занимает десять секунд и сразу отвечает на половину вопросов: что ставить в group by, что считать числителем доли, где искать дубли и какая метрика тут вообще возможна.

Вторая половина вопросов решается второй фразой: какие строки входят в группу. Удаление строк фильтром меняет и числитель, и знаменатель любой последующей доли. Если из одного отчёта отмены исключены, а из другого нет, расхождение между ними не объясняется ни одной из формул — оно объясняется составом групп.

Две фразы перед запросом

Первая: «одна строка результата — это ___». Вторая: «в группу входят строки, у которых ___». Если обе фразы не пишутся без запинки, запрос пока считает не то, что нужно, даже если выполняется без ошибок.

100 строк, 64 заказа и 51 покупатель — это один и тот же запрос

Вернёмся к отчёту из начала. Набор один: пользователи канала paid_search, к которым через left join приклеены их заказы. 100 строк. 64 строки имеют заполненный order_id — это реальные заказы. Уникальных user_id среди строк с заказом 51 — столько людей купили хотя бы раз. Дальше всё зависит от того, какую из трёх функций поставить в колонку и как её подписать.

count(*) не смотрит внутрь строки, поэтому считает и строки-пустышки от left join, у которых от заказа остался только NULL. count(order_id) пропускает NULL и отвечает на вопрос «сколько заказов». count(distinct user_id) убирает повторных покупателей и отвечает «сколько покупателей». Подпись колонки — часть расчёта: заголовок «orders» над значением count(*) превращает 100 строк в 100 заказов без единой правки кода.

Отдельный случай — пустая группа. У канала, где ни один пользователь не купил, count(*) вернёт число пользователей, count(order_id) вернёт 0, а sum(amount) вернёт NULL, а не нуль. В BI такой NULL обычно рисуется прочерком, и канал выглядит как «нет данных», хотя данные есть: выручки ноль. Поэтому денежные колонки в витринах оборачивают в coalesce(sum(amount), 0) — но только в витринах, где ноль действительно означает «продаж не было», а не «не загрузилось».

Что именно считает каждая форма COUNT
ВыражениеСчитаетКогда это нужный ответ
count(*)строки набораобъём фактов на известном зерне
count(order_id)непустые значения колонкисколько заказов нашлось после left join
count(distinct user_id)различные непустые значениясколько людей, а не сколько действий
count(distinct (user_id, order_date))различные комбинациисколько пар «клиент × день»
Один набор, три счётчика

Набор из 100 строк: пользователи paid_search плюс их заказы через LEFT JOIN. 36 строк — пользователи без заказа, у них order_id пустой. Разница 64 и 51 — повторные покупатели.

Значение
Три COUNT на одном наборе строк
select
  u.channel,
  count(*)                         as rows_in_join,   -- 100
  count(o.order_id)                as orders,         -- 64
  count(distinct o.user_id)        as buyers,         -- 51
  coalesce(sum(o.amount), 0)       as revenue
from users u
left join orders o
  on o.user_id = u.user_id
 and o.status = 'paid'
where u.channel = 'paid_search'
group by u.channel;

Агрегаты пропускают NULL, и у AVG из-за этого другой знаменатель

Все агрегаты кроме count(*) игнорируют NULL. Это не особенность диалекта и не настройка, это правило языка. Из него следует неочевидное: у avg знаменатель равен не числу строк в группе, а числу строк с заполненным значением.

Возьми одиннадцать оплаченных заказов, у десяти из которых сумма заполнена и даёт в сумме 13 000 ₽, а у одиннадцатого amount пустой — продажа прошла по внутреннему промо и сумму не записали. count(*) вернёт 11, count(amount) вернёт 10, sum(amount) вернёт 13 000, а avg(amount) вернёт 1 300, потому что делит 13 000 на 10. Напиши avg(coalesce(amount, 0)) — получишь 1 181,8, потому что знаменатель стал 11. Оба числа арифметически верны, и выбирать между ними нужно по смыслу пропуска: пустая сумма означает «бесплатный заказ» или «данные не доехали».

Этот же механизм объясняет, почему sum по сломанной загрузке не выглядит сломанным. Если треть строк приехала с пустой суммой, sum молча сложит остальные две трети и вернёт аккуратное число без предупреждений. Расхождение с бухгалтерией найдут через неделю, и искать будут в атрибуции. Поэтому сравнение count(*) с count(amount) стоит рядом с самой метрикой — как колонка в служебной витрине, а не как отдельная проверка, которую кто-то однажды запустил.

Что на самом деле делает AVG
avg(amount) = sum(amount) / count(amount)

Не count(*). На одиннадцати заказах с одной пустой суммой: 13 000 / 10 = 1 300, а не 13 000 / 11 = 1 181,8.

Проверка полноты рядом с метрикой
select
  count(*)                                    as rows_total,      -- 11
  count(amount)                               as rows_with_amount,-- 10
  count(*) - count(amount)                    as rows_missing,    -- 1
  sum(amount)                                 as revenue,         -- 13000
  avg(amount)                                 as avg_nonnull,     -- 1300.0
  avg(coalesce(amount, 0))                    as avg_with_zeros   -- 1181.8
from orders
where status = 'paid';

Средний чек 1 300 ₽ и средний доход на клиента 1 857 ₽ — оба верны

Возьми десять заказов с заполненной суммой от семи клиентов. Суммы: 1 200, 300, 2 000, 500, 700, 600, 4 800, 900, 1 000, 1 000. Итого 13 000 ₽. Средний чек — это 13 000 / 10 = 1 300 ₽. Средний доход на клиента считается в два шага: сначала сумма по каждому user_id, потом среднее по клиентам. Суммы по клиентам выходят 1 500, 2 000, 1 800, 4 800, 900, 1 000, 1 000 — те же 13 000 ₽, но уже на семь объектов: 1 857 ₽.

Разница в 43% взялась не из данных, а из знаменателя. Клиент с тремя заказами по 500–700 ₽ тянет средний чек вниз, но в среднем на клиента он стоит столько же, сколько однократный покупатель на 1 800 ₽. Ни одно из двух чисел не «точнее»: первое отвечает на вопрос про типичную покупку, второе — про типичного клиента. Они расходятся ровно настолько, насколько различается частота покупок, и это расхождение само по себе метрика.

Отсюда практическое правило: агрегат на одном уровне нельзя получить усреднением агрегата на другом. Средний доход на клиента — это avg по результату группировки, а не avg по заказам. Технически это означает два слоя: CTE с суммами по пользователю, затем внешний агрегат по нему. Тот же двухслойный приём нужен для «среднего числа заказов на клиента», «средней длины сессии на пользователя» и вообще любой метрики, в названии которой есть слово «на».

Длинный хвост добавляет третий вопрос. В тех же семи клиентах медиана дохода — 1 500 ₽, а среднее — 1 857 ₽: один клиент на 4 800 ₽ даёт 37% всей выручки и сдвигает среднее на четверть. Для денежных распределений среднее почти всегда выше медианы, и показывать его в одиночку — значит обещать читателю клиента, которого в данных нет. Рядом со avg держи percentile_cont(0.5) и максимум: три числа вместе описывают форму, одно — только центр тяжести.

Один набор заказов, четыре средних
МетрикаФормулаЗначениеПро кого она
Средний чекsum(amount) / count(*) по заказам1 300 ₽про покупку
Средний доход на клиентаavg по суммам клиентов1 857 ₽про клиента
Медианный доход на клиентаpercentile_cont(0.5)1 500 ₽про типичного клиента
Среднее число заказов на клиента10 / 71,43про частоту
Два знаменателя в одном запросе
with per_user as (
  select user_id, sum(amount) as user_revenue, count(*) as orders
  from orders
  where status = 'paid'
  group by user_id
)
select
  (select count(*)   from orders where status = 'paid')  as orders_total,   -- 10
  (select sum(amount) from orders where status = 'paid') as revenue,        -- 13000
  (select avg(amount) from orders where status = 'paid') as avg_order,      -- 1300
  count(*)                                              as users,          -- 7
  avg(user_revenue)                                     as avg_per_user,   -- 1857.14
  percentile_cont(0.5) within group (order by user_revenue) as median_user, -- 1500
  max(user_revenue)                                     as top_user        -- 4800
from per_user;

MIN(paid_at) без GROUP BY отвечает про таблицу, а не про клиента

min и max — те же агрегаты, и они так же зависят от зерна. min(paid_at) по всей таблице даёт дату первого заказа в истории продукта. min(paid_at) с group by user_id даёт дату первой покупки каждого клиента — ту, от которой считают когорту, время до второй покупки и срок жизни. Это две разные вещи, и опечатка между ними не вызывает ошибки: просто вся когортная разбивка съезжает к одной дате.

У min/max по времени есть своя ловушка — граничные значения чувствительны к мусору сильнее любых других агрегатов. Одна тестовая запись с датой 1970 года становится «первой покупкой» клиента. Один заказ с датой из будущего становится «последней активностью» и ломает расчёт «дней с последнего визита». Средние такой мусор разбавляют, крайние — показывают целиком. Поэтому фильтр валидного диапазона дат нужен именно там, где берутся крайние значения: paid_at >= date '2020-01-01' and paid_at < current_date + 1.

Третий вопрос — что означает отсутствие события. max(login_at) по пользователю без логинов вернёт NULL, и дальше арифметика current_date - max(login_at) тоже даст NULL. В витрине это превращается в пустую ячейку, в фильтре «не заходил больше 30 дней» — в выпадение такого пользователя из выборки, хотя он как раз самый неактивный. Либо считай отсутствие отдельной категорией, либо подставляй дату регистрации, но выбор делай явно, а не оставляй NULL течь по всем следующим расчётам.

Первая и последняя активность на уровне клиента
select
  user_id,
  min(paid_at)::date                            as first_order_date,
  max(paid_at)::date                            as last_order_date,
  count(*)                                      as orders,
  max(paid_at)::date - min(paid_at)::date        as lifespan_days
from orders
where status = 'paid'
  and paid_at >= date '2020-01-01'
  and paid_at < current_date + 1
group by user_id
having count(*) > 1;

SUM после соединения один-ко-многим удваивает выручку

Самая дорогая ошибка агрегации выглядит как безобидное добавление колонки. Отчёт по выручке работал на таблице заказов, затем понадобился регион — его приклеили из таблицы адресов. У клиента два адреса, значит заказ превратился в две строки, и sum(amount) вырос ровно вдвое. Запрос не сломался: он честно сложил то, что в наборе есть.

Диагностика занимает один запрос: посчитай count(*) до соединения и после. Если число строк выросло, зерно изменилось, и все суммы в этом запросе теперь считаются по другому объекту. Неравенство count(*) > count(distinct order_id) — тот же признак, только его удобнее держать в тесте витрины.

Лечится это не distinct, а порядком операций. Сначала агрегируй на правильном зерне, потом присоединяй справочники — к уже свёрнутому результату, в котором одна строка означает один заказ или одного клиента. Если справочник всё равно может дать несколько строк на ключ, сверни и его: возьми один адрес по правилу (последний, основной), а не надейся, что дубль не приедет. sum(distinct amount) в такой ситуации — не решение, а новая ошибка: два честных заказа по 1 000 ₽ он сложит как один.

Агрегат до соединения, справочник после
-- неверно: join размножил заказы, sum посчитал их дважды
select a.region, sum(o.amount) as revenue
from orders o
join user_addresses a on a.user_id = o.user_id
group by a.region;

-- верно: сначала один адрес на клиента, потом агрегат
with primary_address as (
  select user_id, region
  from user_addresses
  where is_primary
)
select a.region, count(*) as orders, sum(o.amount) as revenue
from orders o
join primary_address a on a.user_id = o.user_id
where o.status = 'paid'
group by a.region;
Тождество, которое должно выполняться

Сумма выручки по регионам обязана совпадать с выручкой без разбивки. Если не совпадает — дело не в округлении, а в том, что разбивка изменила зерно: либо размножила строки, либо потеряла заказы, у которых региона нет.

DISTINCT прячет повтор, GROUP BY заставляет его объяснить

select distinct и group by по тем же колонкам без агрегатов дают одинаковый результат, и из этого делают вывод, что разницы нет. Разница не в результате, а в том, что можно сделать дальше. distinct возвращает уникальные комбинации выведенных колонок и на этом заканчивается. group by создаёт группы, к которым прицепляется любой агрегат — и первый же агрегат отвечает на вопрос, который distinct только что закрыл: сколько именно строк он свернул.

Поток событий за июль: 85 000 строк, 79 800 различных event_id, 21 400 различных user_id. Первые два числа расходятся на 5 200 строк — это 6,1% потока, приехавшего дважды с тем же идентификатором. select distinct по всем колонкам эти 5 200 строк уберёт, отчёт станет аккуратным, и никто не узнает, что загрузка дублирует события. group by event_id having count(*) > 1 покажет те же 5 200 строк как проблему, с примерами и временем появления.

Различие event_id и user_id в той же тройке — про другое. 85 000 строк на 21 400 людей означает примерно четыре события на человека, и это нормальное поведение, а не дубль. Поэтому правило уникальности формулируется до запроса и отдельно для каждого уровня: событие уникально по event_id, пользователь — по user_id, пара «пользователь и день» — по их комбинации. Без такого правила distinct становится ставкой на то, что лишние строки окажутся лишними.

У distinct есть ещё одно свойство, из-за которого его ставят зря: он работает по всем выведенным колонкам целиком. Достаточно одной технической колонки с уникальным значением — loaded_at, row_id, etl_batch — и дубли перестают склеиваться, а стоимость сортировки остаётся. Запрос выглядит защищённым, фактически не делает ничего, и это замечают только при сверке.

Что выбрать под задачу
ЗадачаИнструментПочему не наоборот
список значений без повторовselect distinctагрегат не нужен
посчитать повторыgroup by + having count(*) > 1distinct их скроет
метрика по группеgroup bydistinct не даёт агрегат
уникальные люди в метрикеcount(distinct user_id)count(*) считает события
убрать дубли загрузкидедупликация по ключу в пайплайнена чтении они вернутся завтра
Июльский поток событий на трёх уровнях уникальности

85 000 строк против 79 800 событий — 5 200 технических дублей, 6,1% потока. 85 000 против 21 400 пользователей — нормальная повторяемость действий, около четырёх событий на человека.

Количество
Аудит вместо дедупликации
-- три уровня уникальности одного потока
select
  count(*)                    as rows_total,    -- 85 000
  count(distinct event_id)    as unique_events, -- 79 800
  count(distinct user_id)     as unique_users   -- 21 400
from events
where occurred_at >= date '2026-07-01'
  and occurred_at <  date '2026-08-01';

-- что именно приехало дважды
select event_id, count(*) as copies, min(loaded_at) as first_seen, max(loaded_at) as last_seen
from events
where occurred_at >= date '2026-07-01'
  and occurred_at <  date '2026-08-01'
group by event_id
having count(*) > 1
order by copies desc
limit 20;

WHERE не видит алиас, потому что на его шаге алиаса ещё нет

Запрос читается сверху вниз, а выполняется в другом порядке: сначала определяются источники строк и соединения, затем where отбрасывает строки, затем group by собирает группы, having отбрасывает группы, select вычисляет выражения и алиасы, и только потом работают order by и limit. Из этого порядка следуют два правила, которые иначе приходится запоминать как исключения.

Первое: алиас revenue, объявленный в select, на шаге where ещё не существует — как колонка он появится только через три шага. Поэтому where revenue > 500000 даёт ошибку «column revenue does not exist», а не тихий неверный результат. В order by тот же алиас работает, потому что сортировка идёт после select; в group by он работает в PostgreSQL и MySQL, но не в каждом диалекте, так что опираться на это в общем коде не стоит.

Второе: where выполняется до группировки и физически не может сравнивать агрегат — на его шаге групп ещё нет. Условие по sum, count или avg живёт в having. А если одно и то же выражение нужно и показать, и отфильтровать, и отсортировать, его поднимают в CTE: во внешнем запросе агрегат становится обычной колонкой, к которой применим обычный where.

Дальше деление фильтров становится механическим. Условие относится к отдельной строке — status = 'paid', paid_at в периоде, channel in (...) — это where. Условие относится к собранной группе — «каналов, где больше 100 заказов», «клиентов с выручкой выше 50 000 ₽» — это having. Ошибка в этом выборе редко бывает безобидной: она либо падает, либо меняет смысл отчёта, не меняя его внешнего вида.

Куда относится условие
УсловиеУровеньОператор
status = 'paid'исходная строкаWHERE
paid_at внутри периодаисходная строкаWHERE
count(*) >= 100группаHAVING
sum(amount) >= 500000группаHAVING
алиас revenue из SELECTне существует до SELECTCTE, затем WHERE
row_number() <= 3окно, считается после группировкиподзапрос, затем WHERE
Сколько объектов остаётся после каждого шага

Из 100 000 заказов period и status оставляют 62 000 строк. Группировка по каналу даёт 24 группы, из которых пороги по количеству и выручке оставляют 9. Левые два столбца — строки, правые два — группы: это разные единицы измерения на одной оси.

Строк или групп
WHERE по заказам, HAVING по каналам
select
  channel,
  count(*)     as orders_count,
  sum(amount)  as revenue
from orders
where status = 'paid'                      -- уровень строки
  and paid_at >= date '2026-07-01'         -- уровень строки
  and paid_at <  date '2026-08-01'
group by channel
having count(*) >= 100                     -- уровень группы
   and sum(amount) >= 500000               -- уровень группы
order by revenue desc;

Фильтр по дате в HAVING даёт тот же ответ и другой счёт

Формально having умеет сравнивать не только агрегаты: условие по колонке, входящей в group by, там тоже допустимо. group by channel, order_month having order_month >= date '2026-07-01' выполнится и вернёт правильные строки. Но сделает это дороже: база сначала построит группы по всем месяцам истории, а потом выбросит почти все. Чем больше данных, тем заметнее разница, и на больших таблицах она измеряется не процентами.

Есть и вторая причина держать фильтр периода в where. Читатель отчёта понимает, за какой период он смотрит, по условиям в where — это место, где принято объявлять границы выборки. Дата, спрятанная в having среди порогов по выручке, делает период невидимым, и через месяц кто-то добавит рядом ещё один фильтр, не заметив первый.

Обратная крайность — having без group by. Это законная конструкция: весь набор считается одной группой, и having sum(amount) > 0 работает как фильтр по итогу. Смысл при этом понятен только автору: одна группа нигде не объявлена, и читать такой запрос приходится через правило языка, а не через текст. Тот же результат в CTE с внешним where читается без подсказок.

Четыре метрики за один проход: CASE WHEN и FILTER

Когда в отчёте нужны активные, активированные, платящие и получившие ошибку пользователи, первый порыв — написать четыре запроса. Проблема не в объёме работы, а в том, что у четырёх запросов четыре набора условий, и они расходятся. В одном период начинается первого июля, в другом — с понедельника; в одном исключены внутренние аккаунты, в другом забыли. Таблица собирается аккуратная, а доли в ней несопоставимы, потому что относятся к разным базам.

Условная агрегация решает это структурно: база фиксируется один раз в where, а дальше каждая метрика — отдельное условие внутри своего агрегата. count(*) filter (where ...) в PostgreSQL и sum(case when ... then 1 else 0 end) в любом диалекте делают одно и то же; filter короче и не требует объяснять, почему единица складывается.

Разница между count(case when ... then 1 end) и sum(case when ... then 1 else 0 end) тоньше, чем кажется. В первом варианте case без else возвращает NULL для непопавших строк, а count пропускает NULL — и получается корректный счётчик. Во втором нули складываются и дают то же число. Но стоит написать count(case when ... then 1 else 0 end) — и счётчик сломается: count посчитает и нули, вернув общее число строк. Эта опечатка не падает и не выглядит подозрительно.

Доли считаются в том же проходе, с двумя оговорками. Делить нужно на nullif(знаменатель, 0), иначе пустая группа уронит запрос делением на ноль. И приводить к дробному типу — ::numeric или умножение на 1.0, — иначе целочисленное деление вернёт 0 вместо 0,42 в PostgreSQL и в большинстве аналитических движков.

Чем заменить FILTER там, где его нет
ЗадачаPostgreSQLПереносимый вариант
посчитать строки по условиюcount(*) filter (where c)sum(case when c then 1 else 0 end)
посчитать людей по условиюcount(distinct user_id) filter (where c)count(distinct case when c then user_id end)
сумму по условиюsum(amount) filter (where c)sum(case when c then amount else 0 end)
долючислитель::numeric / nullif(знаменатель, 0)та же формула с приведением типа
Пять метрик и две доли одним проходом
select
  date_trunc('week', occurred_at)                                  as week,
  count(distinct user_id)                                          as active_users,
  count(distinct user_id) filter (where event_name = 'core_action') as activated_users,
  count(distinct user_id) filter (where event_name = 'purchase')    as paying_users,
  count(*)                filter (where event_name = 'error')       as error_events,
  round(
    count(distinct user_id) filter (where event_name = 'purchase')::numeric
    / nullif(count(distinct user_id), 0), 4
  )                                                                 as paying_rate
from events
where occurred_at >= date '2026-07-01'
  and occurred_at <  date '2026-08-01'
  and is_internal = false
group by 1
order by 1;

COUNT(DISTINCT user_id) FILTER: числитель обязан быть подмножеством знаменателя

Условная агрегация даёт сопоставимость, но не проверяет её. Проверка — отдельная арифметика, и она простая: каждый числитель должен быть подмножеством знаменателя, а значит не превышать его. На июльской базе: 10 000 зарегистрированных, 6 800 активных, 4 200 активированных, 1 700 платящих. Неравенство 1 700 ≤ 4 200 ≤ 6 800 ≤ 10 000 выполняется — и это первое, что стоит проверить после сборки, потому что нарушение сразу называет причину.

Нарушения бывают двух видов. Первый: числитель считается по другому набору строк, чем знаменатель — например, активированных берут из всех событий, а активных только из мобильного приложения. Тогда активированных может оказаться больше активных, и доля перевалит за 100%. Второй: числитель и знаменатель считают разные объекты. count(*) filter (...) в числителе и count(distinct user_id) в знаменателе дадут «конверсию» больше единицы просто потому, что один человек совершил несколько покупок. Смешивать count(*) и count(distinct) в одной доле можно только осознанно, и тогда подпись должна называть объект: «покупок на активного пользователя», а не «конверсия».

Отдельная развилка — окно события, и SQL её не решает. «Активация» может означать «core_action в день регистрации», «в течение семи дней» и «когда-нибудь». Три определения дают три разных числа на одних данных, и все три корректны. Поэтому окно пишется в название колонки или в комментарий рядом: activated_7d, а не activated. Колонка без окна через квартал превращается в спор, в котором нет правой стороны.

Последняя проверка — на устойчивость к дублям. Добавь в тестовый набор копию одного события и пересчитай: count(distinct user_id) не должен измениться, count(*) изменится. Если пользовательская метрика дрогнула, значит где-то в ней считаются строки, а не люди.

Что проверить у каждой доли
МетрикаЧислительЗнаменательКонтроль
Activation 7dcore_action в 7 днейвсе зарегистрированныеокно указано в имени колонки
Paying rateплатящиеактивныеодна таймзона и один период
Core ratecore_actionактивныеодно и то же событие во всех строках
Error rateсобытия с ошибкойвсе событияи числитель, и знаменатель — строки
Вложенные подмножества одной базы

Каждая следующая метрика — подмножество предыдущей: 1 700 ≤ 4 200 ≤ 6 800 ≤ 10 000. Paying rate от активных: 1 700 / 6 800 = 25%. От зарегистрированных: 1 700 / 10 000 = 17%. Одно и то же число в числителе, две разные метрики.

Пользователей

Колонки вместо PIVOT: широкий отчёт — это та же условная агрегация

В BI часто нужна одна строка на сегмент и отдельная колонка на каждую метрику. Слово PIVOT для этого не обязательно: стандартного оператора в PostgreSQL нет, а нужная операция уже описана выше. Категорию превращают в колонку тем же filter или case, просто условие теперь проверяет не событие, а значение признака.

Ключевое требование — собрать широкий отчёт поверх одного длинного слоя, а не соединять несколько отдельных агрегатов по ключу сегмента. Соединение даёт две типовые поломки: сегмент, которого нет в одном из агрегатов, теряется целиком при join и приносит NULL при left join; и у каждого агрегата оказывается свой фильтр, который никто не сверял. Один group by поверх общего слоя снимает оба вопроса сразу.

Сумма колонок при этом становится проверкой. По трём каналам активных 3 100 + 2 200 + 1 500 = 6 800, платящих 760 + 520 + 420 = 1 700, ушедших 380 + 340 + 200 = 920 — ровно те же итоги, что в разбивке по метрикам. Если сумма по сегментам меньше общего итога, у части строк признак сегмента пустой, и эти пользователи молча выпали из отчёта: им нужна своя строка «не определён», а не исключение из таблицы.

NULL в широкой таблице читается иначе, чем в длинной. Пустая ячейка в колонке churned_users означает либо «таких не было», либо «условие вообще не проверялось». Поэтому счётчики оборачивают в coalesce(..., 0), а доли оставляют пустыми — ноль в доле утверждал бы, что знаменатель был. И последнее: широкая схема фиксирована. Появится четвёртый статус — он не появится в отчёте сам, его придётся дописать руками. Длинный слой при этом примет новое значение без правок, поэтому его и держат источником, а широкий вид собирают сверху.

Широкий отчёт по каналам и контрольные суммы
КаналActivePaidChurnedPaid / Active
organic3 10076038024,5%
paid_search2 20052034023,6%
referral1 50042020028,0%
Итого6 8001 70092025,0%
Метрики по колонкам поверх одного user-level слоя
with user_state as (
  select
    u.user_id,
    u.channel,
    u.status                                   -- active / paid / churned
  from users u
  where u.registered_at < date '2026-08-01'
    and u.is_internal = false
)
select
  coalesce(channel, 'не определён')                                  as channel,
  count(*)                                                           as users_total,
  count(*) filter (where status = 'active')                           as active_users,
  count(*) filter (where status = 'paid')                             as paid_users,
  count(*) filter (where status = 'churned')                          as churned_users,
  round(count(*) filter (where status = 'paid')::numeric
        / nullif(count(*) filter (where status = 'active'), 0), 3)     as paid_per_active
from user_state
group by 1
order by users_total desc;

Проверки, которые ловят неверный агрегат до дашборда

Агрегация почти никогда не падает. Она возвращает число, которое выглядит разумно и отвечает не на тот вопрос. Поэтому проверки здесь арифметические: ты сравниваешь полученное с тем, что обязано получиться по определению метрики.

  • Зерно результата записано одной фразой, и count(*) на этом зерне равен count(distinct ключ).
  • count(*) не изменился после добавления нового join — иначе суммы в запросе больше не про тот объект.
  • У каждой денежной колонки рядом посчитано count(*) - count(column): пропуски найдены, а не пропущены.
  • Сумма по разбивке совпадает с итогом без разбивки; строки с пустым признаком сегмента не исчезли.
  • Каждая доля проверена неравенством «числитель не больше знаменателя» и считает в обеих частях один тип объекта.
  • В названии метрики «на клиента» стоит два слоя агрегации, а не один avg по строкам.
  • Фильтры периода и технических аккаунтов стоят в where один раз и одинаковы для всех колонок отчёта.
  • Окно события указано в имени колонки: activated_7d, paid_30d, а не activated и paid.
  • Дубль одного события в тестовом наборе не сдвигает пользовательские метрики.
  • Пустая группа даёт 0 там, где ноль означает факт, и NULL там, где данных действительно нет.

Практика: один набор заказов, пять разных ответов

Собери таблицу из десяти оплаченных заказов от семи клиентов с суммами 1 200, 300, 2 000, 500, 700, 600, 4 800, 900, 1 000, 1 000 и одиннадцатой строкой, у которой amount пустой. Посчитай на ней пять чисел: число строк, число заказов с суммой, число клиентов, средний чек и средний доход на клиента. Сверь с 11, 10, 7, 1 300 и 1 857 — расхождение означает, что где-то знаменатель не тот, о котором ты думал.

Потом приклей к этим заказам таблицу адресов, в которой у одного клиента два адреса, и пересчитай выручку. Она вырастет, и задача — объяснить на сколько именно: ровно на сумму заказов клиента с двумя адресами. Перепиши запрос так, чтобы агрегат считался до соединения, и убедись, что 13 000 ₽ вернулись.

Третий шаг — условная агрегация. Собери по неделям пять колонок: активные, активированные, платящие, пользователи с ошибкой и доля платящих от активных. Проверь вложенность подмножеств, затем продублируй одно событие и посмотри, какие колонки дрогнули. Те, что дрогнули, считают строки; если среди них есть колонка с подписью «пользователи», её нужно переписать на count(distinct user_id).

И последнее: разверни тот же отчёт по каналам в широкий вид и сложи колонки. Сумма должна совпасть с итогом по метрикам. Если не совпала, найди пользователей с пустым каналом — они и есть разница.

Итог: агрегат считает то зерно, которое ты назвал

Семь вопросов из начала решаются одним ходом. count(*) против count(order_id) — вопрос о том, строка или заполненное значение. distinct против group by — вопрос о том, нужен ли дальше агрегат. where против having — вопрос о том, фильтруется строка или группа. Условная агрегация и раскладка по колонкам — способ задать все вопросы к одной базе сразу, чтобы ответы остались сравнимыми. Во всех случаях сначала определяется объект, потом выбирается функция.

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

Продолжить чтение
Вся библиотека
Продуктовая аналитика29 июля 2026 г.15 мин

UNION и UNION ALL в SQL: где объединение незаметно теряет строки

Чем UNION отличается от UNION ALL: неявный DISTINCT по всей строке, поведение NULL, цена сортировки, порядок и типы колонок, и как объединить события web и app, не потеряв четверть потока.

Читать материал
Продуктовая аналитика24 августа 2026 г.13 мин

Дедупликация событий в SQL: как убрать дубли и не потерять реальные действия

Как отличить повторную доставку события от повторного действия пользователя, выбрать ключ дедупликации и окно, оставить одну строку через ROW_NUMBER и проверить, что после очистки не исчезли настоящие заказы.

Читать материал