Агрегаты в аналитике: чек-лист для PostgreSQL 16
Сумма в отчёте может выглядеть правдоподобно и при этом учитывать заказ несколько раз. Проверьте зерно группировки, связи таблиц, NULL и временные границы — до того, как цифры попадут в дашборд.

В отчёте по выручке один заказ может превратиться в три, если соединить его с тремя строками товаров и затем суммировать поля из таблицы заказов. Запрос выполнится без ошибки, но цифры окажутся неверными. Ниже — чек-лист для агрегатов в PostgreSQL 16: он помогает проверить смысл метрик до того, как результат уйдёт в отчёт или дашборд.
Сначала зафиксируйте, что считается одной строкой
До выбора SUM, COUNT или AVG сформулируйте зерно результата: одна строка на заказ, на клиента, на день или на сочетание нескольких измерений. Это не формальность: зерно определяет, какие строки должны попасть в агрегат.
Термин. Зерно данных — уровень детализации строки. Например, строка заказа и строка товара в заказе имеют разное зерно: у одного заказа может быть несколько товарных строк.
- Назовите будущую строку результата: например, «одна строка на месяц и статус заказа».
- Проверьте, что столбцы в
GROUP BYзадают именно этот уровень, а не случайно дробят результат. - Для каждого показателя запишите единицу счёта: заказ, товарная строка, пользователь или рубль.
Если отчёт должен показывать выручку по месяцам, но в группировку попал ещё и идентификатор заказа, вы получите строки по заказам, а не месячные итоги. Запрос корректен для SQL, но не отвечает на вопрос отчёта.
Проверьте соединения до подсчёта
Главный источник завышенных сумм — связь «один ко многим». Допустим, orders содержит итог заказа, а order_items — отдельные товары. После соединения заказ с тремя товарами появится в трёх строках. Поэтому SUM(orders.total) посчитает его итог трижды.
| Подход | Когда уместен | Цена и риск |
|---|---|---|
| Считать после прямого JOIN | Нужна метрика по строкам дочерней таблицы, например сумма количества товаров | Поля родительской таблицы размножаются; для числа заказов потребуется отдельная проверка зерна |
| Сначала агрегировать дочернюю таблицу | Нужен результат на уровне заказа, а затем месячный итог | Добавляется промежуточный шаг, зато в соединении остаётся не более одной строки на заказ |
| COUNT(DISTINCT id) | Нужно посчитать уникальные сущности после размножающего JOIN | Исправляет именно подсчёт уникальных идентификаторов, но не защищает другие суммы от удвоения |
Для суммы товарных строк и количества заказов безопаснее сначала свернуть товары до уровня заказа, а потом присоединить их к заказам. Пример для PostgreSQL 16; названия таблиц и столбцов здесь условные.
WITH item_totals AS (
SELECT
order_id,
SUM(quantity * unit_price) AS item_revenue
FROM order_items
GROUP BY order_id
)
SELECT
date_trunc('month', o.created_at) AS month,
COUNT(*) AS order_count,
COALESCE(SUM(i.item_revenue), 0) AS item_revenue
FROM orders AS o
LEFT JOIN item_totals AS i ON i.order_id = o.id
WHERE o.status = 'paid'
AND o.created_at >= TIMESTAMP '2025-01-01 00:00:00'
AND o.created_at < TIMESTAMP '2025-02-01 00:00:00'
GROUP BY date_trunc('month', o.created_at)
ORDER BY month;
Здесь промежуточный результат содержит максимум одну строку на заказ. Поэтому COUNT(*) считает заказы, а не товары. Если заказ без товарных строк всё же возможен, его итог в этой выборке будет нулём благодаря COALESCE. Уточните отдельно, соответствует ли это правилам вашей метрики.
Чек-лист перед публикацией отчёта
- Сверьте зерно. Скажите вслух, что означает одна строка результата. Проверьте, что группировка не включает лишние поля и не скрывает нужные разрезы.
- Проследите путь каждой суммы. Для каждого JOIN выясните, сколько строк одной исходной сущности появится после соединения. Если число может вырасти, не суммируйте без проверки поля родительской таблицы.
- Разведите виды счёта. В PostgreSQL
COUNT(*)считает строки, аCOUNT(column)— значения столбца, кромеNULL. ПослеLEFT JOINстрока без совпадения всё равно существует: для количества найденных дочерних записей обычно считают её ненулевой ключ, а не*. - Определите смысл NULL.
SUMне учитывает NULL, а если входных строк нет, возвращает NULL. Заменяйте его на ноль черезCOALESCEтолько если отсутствие данных действительно означает нулевой результат, а не неизвестное значение. - Проверьте границы периода. Для последовательных интервалов удобно задавать начало включительно, конец исключительно: от начала месяца до начала следующего. Так запись ровно на границе не попадёт сразу в два отчётных периода.
- Проверьте условие до или после группировки.
WHEREотбирает исходные строки до агрегации;HAVINGотбирает уже сформированные группы. Перенос условия между ними может изменить сам показатель. - Сверьте итог с независимой выборкой. На небольшом интервале сравните число заказов с количеством уникальных идентификаторов, а сумму — с контрольным расчётом на уровне первичных строк. Расхождение сначала объясните, затем публикуйте график.
Когда выбирать COUNT DISTINCT, а когда предварительную агрегацию
COUNT(DISTINCT o.id) полезен, когда после JOIN нужно посчитать уникальные заказы. Но он не делает безопасным SUM(o.total): сумма всё ещё может повторять одно значение для каждой строки товара. Если в одном запросе есть и счёт заказов, и выручка по товарам, разберите их зерно отдельно — предварительная агрегация часто делает логику прозрачнее.
Агрегат не знает, что вы считаете: заказ, товар или клиента. Он складывает и считает строки, которые вы ему передали.
Не добавляйте DISTINCT как универсальный пластырь. Он способен замаскировать ошибку соединения и изменить смысл метрики. Например, сумма двух одинаковых по величине покупок после SUM(DISTINCT amount) учтёт значение только один раз, хотя покупок было две.
Что ломается на границах
- Пустая группа. Для периода без строк агрегат может вернуть NULL, а не ноль. Решите, как пустой период должен выглядеть в отчёте, и задайте это явно.
- NULL в ключах и показателях.
COUNT(column)пропускает NULL, а обычные агрегаты не превращают пропуск в ноль автоматически. Проверьте, не означает ли NULL «данные не загрузились». - Дробление группировки. Включённый в
GROUP BYстатус или категория может разделить итог на несколько строк. Это правильно только если такой разрез нужен пользователю отчёта. - Тяжёлый DISTINCT. Подсчёт уникальных значений на большом наборе данных может требовать заметных ресурсов. Не угадывайте по размеру таблицы: проверьте план и время выполнения на данных, близких к рабочим.
Инвалидация кэша и устройство витрин данных — отдельные темы; здесь речь только о корректности SQL-агрегатов.
Что проверить дальше
Возьмите один показатель из рабочего дашборда и проследите его от исходных таблиц до итоговой строки: зерно, JOIN, фильтры, NULL и временной диапазон. Затем сравните число строк и сумму с контрольным расчётом на небольшом периоде. Если запрос медленный, изучите план выполнения на безопасной копии или реплике: EXPLAIN ANALYZE действительно запускает запрос, поэтому не используйте его бездумно на тяжёлой операции в рабочей базе.
Примеры и поведение функций сверяйте с документацией PostgreSQL 16: синтаксис и детали могут отличаться в другой СУБД или версии.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: PostgreSQL 16: функции агрегирования, PostgreSQL 16: SELECT и группировка
Частые вопросы
Почему после JOIN сумма стала больше?
Когда использовать COUNT(*) и когда COUNT(id)?
Можно ли исправить завышенную сумму через DISTINCT?
Что делать, если агрегат вернул NULL вместо нуля?
Подойдёт ли пример для другой версии PostgreSQL?
Как проверить запрос на проде, не устроив нагрузку?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.