Агрегация: считаем бизнес-показатели

COUNT, SUM, AVG: строки превращаются в числа

25 мин
Чему научишься
  • писать запросы с COUNT, SUM, AVG, MIN и MAX
  • понимать главную идею : много строк превращаются в одну итоговую сводку
  • считать количество строк, сумму, среднее, минимум и максимум
  • сочетать WHERE с агрегатами, чтобы считать итог только по нужным строкам
  • отличать COUNT(*), COUNT(col) и COUNT(DISTINCT col)
  • объяснять, почему агрегаты обычно игнорируют NULL
  • понимать, что вернут агрегаты, если после фильтра не осталось строк
  • объяснять, почему SELECT status, COUNT(*) FROM orders без GROUP BY падает с ошибкой

Забегаем вперёд.

В этой главе некоторые примеры могут склеивать две таблицы через JOIN — это тема Главы 4, и подробно мы её разберём там.

Пока строку вроде:

orders o
JOIN order_items oi ON oi.order_id = o.id

читай просто так:

строки заказов склеили со строками товаров заказа по общему ключу.

Сейчас важны не , а : как SQL считает итог по набору строк.

Глава 3 — «Пульс магазина»

Допуск «Аналитик» ложится на твой пропуск тёплой янтарной полосой.

Зашифрованный блок К. так и не открылся, но его заголовок ты помнишь наизусть:

Сырые строки — шум.
Смысл появляется, когда ты умеешь их складывать.

КВЕРИ гасит лишние голопанели и оставляет одну — пустую, с одинокой пульсирующей точкой посередине.

КВЕРИ: Ты читал архив построчно. Хватит. Сегодня послушаем его сердце.

Курсант у голопанели: поток строк-капсул сжимается в одну пульсирующую точку-показатель
Агрегат сжимает сотни строк в один удар пульса — число, которое можно произнести вслух.

До этого момента ты в основном доставал строки.

Например:

SELECT id, user_id, status, total_amount
FROM orders
WHERE status = 'paid';

Такой запрос показывает оплаченные заказы один за другим:

iduser_idstatustotal_amount
1017paid1200
1029paid3500
1037paid800
10412paid2100

Это полезно, если нужно посмотреть сами строки.

Но часто бизнесовый вопрос звучит иначе:

Сколько было оплаченных заказов?
Какая общая выручка?
Какой средний чек?
Какой был минимальный и максимальный чек?
Сколько разных покупателей сделали заказ?

На такие вопросы не нужен длинный список строк. Нужна сводка.

Для этого в SQL есть .

Агрегатная функция берёт набор строк и сворачивает его в одно итоговое значение.

Например:

SELECT COUNT(*) AS orders_count
FROM orders;

Этот запрос не показывает каждый заказ отдельно. Он возвращает одно число: сколько строк есть в таблице orders.

Агрегатные функции: много строк → одно значение

Агрегаты превращают поток строк в показатель, который можно произнести вслух.

Не:

заказ 101
заказ 102
заказ 103
заказ 104

А:

всего 4 заказа

Не:

1200
3500
800
2100

А:

общая сумма 7600
средний чек 1900
минимальный чек 800
максимальный чек 3500

Пять основных агрегатных функций:

ФункцияЧто считаетПример вопроса
COUNT(*)количество строкСколько заказов?
SUM(x)сумму значенийКакая выручка?
AVG(x)среднее значениеКакой средний чек?
MIN(x)минимальное значениеКакой самый маленький чек?
MAX(x)максимальное значениеКакой самый большой чек?

Например:

SELECT
    COUNT(*) AS orders_count,
    SUM(total_amount) AS revenue,
    AVG(total_amount) AS avg_order_amount,
    MIN(total_amount) AS min_order_amount,
    MAX(total_amount) AS max_order_amount
FROM orders;

Если в запросе нет GROUP BY, считают итог по всему набору строк, который дошёл до них.

То есть результатом будет одна строка:

orders_countrevenueavg_order_amountmin_order_amountmax_order_amount
4760019008003500
9905990129034902790SUM14 550одно число
Агрегатная функция сжимает поток строк в одно итоговое значение — один показатель на весь набор.

COUNT: сколько строк

COUNT отвечает на вопрос «сколько?».

Самая частая форма:

COUNT(*)

Она считает строки.

Например:

SELECT COUNT(*) AS orders_count
FROM orders;

Результат:

orders_count
128

Звёздочка в COUNT(*) не означает «выбери все столбцы». Здесь она означает:

считай сами строки.

COUNT(*) не смотрит, какие значения лежат внутри столбцов. Ему важно только, существует строка или нет.

Поэтому COUNT(*) подходит, когда вопрос звучит так:

Сколько всего заказов?
Сколько всего товаров?
Сколько всего пользователей?
Сколько строк осталось после фильтра?

Например, сколько оплаченных заказов:

SELECT COUNT(*) AS paid_orders_count
FROM orders
WHERE status = 'paid';

Здесь сначала WHERE оставляет только оплаченные заказы, а потом COUNT(*) считает оставшиеся строки.

SUM, AVG, MIN и MAX

SUM считает сумму.

SELECT SUM(total_amount) AS revenue
FROM orders
WHERE status = 'paid';

Так можно получить выручку по оплаченным заказам.

AVG считает среднее значение.

SELECT AVG(total_amount) AS avg_order_amount
FROM orders
WHERE status = 'paid';

Так можно получить средний чек по оплаченным заказам.

MIN ищет минимальное значение.

SELECT MIN(total_amount) AS min_order_amount
FROM orders
WHERE status = 'paid';

MAX ищет максимальное значение.

SELECT MAX(total_amount) AS max_order_amount
FROM orders
WHERE status = 'paid';

Все эти функции можно использовать вместе:

SELECT
    COUNT(*) AS paid_orders_count,
    SUM(total_amount) AS revenue,
    AVG(total_amount) AS avg_order_amount,
    MIN(total_amount) AS min_order_amount,
    MAX(total_amount) AS max_order_amount
FROM orders
WHERE status = 'paid';

Так один запрос возвращает сводку по оплаченным заказам.

WHERE сначала отбирает строки, потом агрегат считает

Важно понимать порядок мысли.

В запросе:

SELECT
    COUNT(*) AS paid_orders_count,
    SUM(total_amount) AS revenue
FROM orders
WHERE status = 'paid';

сначала работает фильтр:

WHERE status = 'paid'

Он оставляет только оплаченные заказы.

И только потом считают итог по оставшимся строкам:

COUNT(*)
SUM(total_amount)

То есть запрос не считает все заказы, а потом как-то помечает оплаченные. Он сначала убирает лишние строки, а затем считает итог.

Это удобно для любых уточнений.

Выручка только по оплаченным заказам:

WHERE status = 'paid'

Количество отменённых заказов:

WHERE status = 'cancelled'

Средний чек за определённый период:

WHERE created_at >= '2024-01-01'
  AND created_at <  '2024-02-01'

Сначала формируется набор строк. Потом агрегат превращает его в число.

КВЕРИ: Прежде чем слушать пульс, выбери, чьё сердце слушаешь. Все заказы, оплаченные заказы и отменённые заказы звучат по-разному.

Слушаем сердце магазина: одна строка со всеми жизненными показателями — сколько оплаченных заказов, сколько разных покупателей за ними стоит, какая выручка и где границы чека. ROUND(...,2) округляет среднее до копеек.
Результат запроса
всего_заказовпокупателейвыручкасредний_чекминимуммаксимум
86351381230.0016060.81890.0042110.00

COUNT(*) и COUNT(DISTINCT col) отвечают на разные вопросы.

COUNT(*) считает строки.

COUNT(DISTINCT user_id) считает, сколько разных покупателей встречается в этих строках.

Если заказов больше, чем покупателей, это не сбой архива: один покупатель мог сделать несколько заказов.

COUNT(*), COUNT(col), COUNT(DISTINCT col)

У COUNT есть несколько форм, и они отвечают на разные вопросы.

Разберём на маленьком примере.

Пусть есть таблица visits:

idpromo_code
1SALE
2SALE
3
4VIP
5NULL

Запрос:

SELECT
    COUNT(*) AS rows_count,
    COUNT(promo_code) AS with_promo_code,
    COUNT(DISTINCT promo_code) AS different_promo_codes
FROM visits;

вернёт:

rows_countwith_promo_codedifferent_promo_codes
532

Почему так?

COUNT(*) считает все строки.

В таблице 5 строк, значит результат — 5.

COUNT(promo_code) считает строки, где promo_code заполнен.

Значения NULL он не считает. Заполненные промокоды есть в трёх строках:

SALE
SALE
VIP

Значит результат — 3.

COUNT(DISTINCT promo_code) считает разные заполненные значения.

Из заполненных значений:

SALE
SALE
VIP

разных только два:

SALE
VIP

Значит результат — 2.

Итого:

COUNT(*)                 → сколько строк
COUNT(promo_code)        → у скольких строк промокод заполнен
COUNT(DISTINCT promo_code) → сколько разных заполненных промокодов

Это три разных вопроса к данным, поэтому ответы могут быть разными.

Агрегаты и NULL

Общее правило:

обычно игнорируют NULL.

Например, есть значения:

total_amount
1000
2000

SUM(total_amount) сложит только заполненные значения:

1000 + 2000 = 3000

AVG(total_amount) тоже возьмёт только заполненные значения:

(1000 + 2000) / 2 = 1500

Не так:

(1000 + 2000 + NULL) / 3

MIN и MAX тоже ищут минимум и максимум среди заполненных значений.

Единственное важное исключение — COUNT(*).

COUNT(*) считает строки, а не значение конкретного столбца, поэтому NULL внутри столбцов его не волнует.

Сравни:

SELECT
    COUNT(*) AS rows_count,
    COUNT(total_amount) AS filled_amounts,
    SUM(total_amount) AS total_sum,
    AVG(total_amount) AS avg_amount
FROM orders;

COUNT(*) ответит на вопрос:

сколько строк

COUNT(total_amount) ответит на вопрос:

у скольких строк заполнен total_amount

Это не одно и то же.

Если после WHERE не осталось строк

Иногда фильтр может не найти ни одной строки.

Например:

SELECT
    COUNT(*) AS orders_count,
    SUM(total_amount) AS revenue,
    AVG(total_amount) AS avg_order_amount,
    MIN(total_amount) AS min_order_amount,
    MAX(total_amount) AS max_order_amount
FROM orders
WHERE status = 'status_that_does_not_exist';

Если таких заказов нет, результат всё равно будет одной строкой, потому что без GROUP BY возвращает одну сводку по выбранному набору.

Но значения будут разными:

orders_countrevenueavg_order_amountmin_order_amountmax_order_amount
0NULLNULLNULL

COUNT(*) вернёт 0, потому что строк нет.

А SUM, AVG, MIN и MAX вернут NULL, потому что им нечего суммировать, усреднять или сравнивать.

Если в отчёте вместо NULL нужна нулевая выручка, можно использовать COALESCE:

SELECT
    COUNT(*) AS orders_count,
    COALESCE(SUM(total_amount), 0) AS revenue
FROM orders
WHERE status = 'status_that_does_not_exist';

Это не меняет данные в таблице. Просто в результате запроса вместо пустого итога показывается 0.

COUNT(*) и COUNT(1)

Иногда в чужих запросах можно встретить:

COUNT(1)

Например:

SELECT COUNT(1)
FROM orders;

В PostgreSQL для обычного подсчёта строк это по смыслу то же самое, что:

SELECT COUNT(*)
FROM orders;

Почему?

Потому что 1 — это выражение, которое не бывает NULL. В каждой строке оно существует, значит COUNT(1) считает каждую строку.

Но для читаемости лучше писать:

COUNT(*)

Так сразу видно, что ты считаешь строки.

Миф о том, что COUNT(1) обязательно быстрее COUNT(*), для PostgreSQL не является полезным правилом. На этом уровне запомни проще:

нужны строки — пиши COUNT(*).

Почему обычный столбец рядом с агрегатом даёт ошибку

Посмотри на запрос:

SELECT status, COUNT(*)
FROM orders;

Кажется, что он должен показать статус и количество заказов.

Но без GROUP BY такой запрос падает с ошибкой.

Почему?

COUNT(*) без GROUP BY сворачивает все выбранные строки в одну итоговую строку.

Например:

count
128

А status — это обычный столбец. В таблице у разных заказов разные статусы:

idstatus
1paid
2pending
3paid
4cancelled

Если весь набор строк схлопнулся в одну строку результата, какой status SQL должен показать рядом с общим количеством?

paid?
pending?
cancelled?

SQL не угадывает. Он требует, чтобы ты явно описал логику.

Если нужен общий счётчик всех заказов — убери status:

SELECT COUNT(*) AS orders_count
FROM orders;

Если нужно количество заказов по каждому статусу — добавь GROUP BY:

SELECT status, COUNT(*) AS orders_count
FROM orders
GROUP BY status;

Тогда результат будет не одна общая строка, а отдельная строка на каждый статус:

statusorders_count
paid80
pending25
cancelled18
refunded5

GROUP BY подробно разберём дальше. Пока важно понять саму причину ошибки:

схлопывает строки, а обычный столбец остаётся многозначным. SQL не выбирает одно значение за тебя.

Главное правило без GROUP BY

Если в запросе нет GROUP BY, агрегатные функции считают итог по всему набору строк, который остался после WHERE.

SELECT COUNT(*), SUM(total_amount)
FROM orders
WHERE status = 'paid';

Такой запрос вернёт одну строку.

Обычный столбец рядом с агрегатом без группировки писать нельзя:

SELECT status, COUNT(*)
FROM orders;

Такой запрос некорректен, потому что status может быть разным у разных строк.

Вопрос с собеседования

Вопрос с собеседования:
В таблице сто строк, в столбце manager_id часть значений — NULL. Что вернут COUNT(*), COUNT(manager_id) и COUNT(DISTINCT manager_id) и почему числа разные?

Сильный ответ:
COUNT(*) вернёт 100, потому что он считает строки и не смотрит на NULL внутри столбцов. COUNT(manager_id) вернёт меньше или равно 100: он считает только строки, где manager_id заполнен, потому что COUNT(col) игнорирует NULL. COUNT(DISTINCT manager_id) вернёт количество разных непустых manager_id, поэтому это число будет меньше или равно COUNT(manager_id). Это три разных вопроса: сколько строк, у скольких строк заполнен менеджер, сколько разных менеджеров встречается.


Вопрос с собеседования:
Почему SELECT status, COUNT(*) FROM orders без GROUP BY — ошибка?

Сильный ответ:
COUNT(*) без GROUP BY схлопывает весь набор строк в одну итоговую строку, а status — обычный столбец, у которого может быть много разных значений в разных строках. SQL не угадывает, какой статус показать рядом с общим количеством. Нужно либо убрать status, если нужен общий итог, либо добавить GROUP BY status, если нужен счётчик по каждому статусу.


Вопрос с собеседования:
Чем SUM(total_amount) отличается от COUNT(*) на пустом наборе строк?

Сильный ответ:
Если после WHERE не осталось строк, COUNT(*) вернёт 0, потому что строк действительно ноль. А SUM(total_amount) вернёт NULL, потому что суммировать нечего. Если в отчёте нужна нулевая сумма, можно обернуть в COALESCE(SUM(total_amount), 0).

Проверь себя
Что даст запрос SELECT AVG(price) FROM products;, если GROUP BY не указан?
Проверь себя
Что считает COUNT(*)?
Проверь себя
Чем COUNT(user_id) отличается от COUNT(DISTINCT user_id)?
Проверь себя
Почему запрос SELECT status, COUNT(*) FROM orders; без GROUP BY некорректен?

КВЕРИ: Сырые строки показывают события. Агрегаты показывают пульс: сколько, на какую сумму, в каких границах и с каким средним ритмом.