COUNT, SUM, AVG: wiersze zamieniają się w liczby
What you'll learn
- pisać zapytania z
COUNT,SUM,AVG,MINiMAX, które zwijają zbiór wierszy do jednego podsumowania - łączyć
WHEREz agregatami, żeby liczyć tylko potrzebne wiersze — na przykład opłacone zamówienia - odróżniać
COUNT(*),COUNT(col)iCOUNT(DISTINCT col)oraz wyjaśniać, dlaczego na tych samych danych dają trzy różne liczby - wyjaśniać, dlaczego
SELECT status, COUNT(*)bezGROUP BYkończy się błędem
Wybiegamy naprzód. W tym rozdziale niektóre przykłady łączą dwie tabele przez JOIN — to temat Rozdziału 4, gdzie omówimy go dokładnie. Na razie czytaj fragment w rodzaju orders o JOIN order_items oi ON oi.order_id = o.id po prostu jako „połączyliśmy wiersze dwóch tabel po kluczu”. Teraz liczą się same agregaty, nie JOIN.
Rozdział 3 — „Puls sklepu”
Na twojej przepustce zapala się ciepły, bursztynowy pasek dostępu „Analityk”. Zaszyfrowany blok K. wciąż się nie otworzył, ale jego nagłówek znasz już na pamięć: „Surowe wiersze to szum. Sens pojawia się, gdy potrafisz je dodawać”. QUERY wygasza zbędne holopanele i zostawia jeden — pusty, z samotną pulsującą kropką pośrodku.
QUERY: Czytałeś archiwum wiersz po wierszu. Wystarczy. Dziś posłuchamy jego serca.
Funkcje agregujące: wiele wierszy → jedna liczba
Zwykły SELECT pokazuje wiersze takimi, jakie są: zamówienie po zamówieniu, produkt po produkcie. Funkcja agregująca zwija zbiór wierszy we wskaźnik, który da się wypowiedzieć na głos: ile było zamówień, jaki był przychód, jaka była średnia wartość zamówienia. Dla archiwisty to właśnie puls: jedna liczba, w której słychać cały strumień.
Pięć podstawowych narzędzi:
| Funkcja | Co liczy |
|---|---|
COUNT(*) | liczbę wierszy, na przykład zamówień |
SUM(x) | sumę wartości kolumny |
AVG(x) | wartość średnią |
MIN(x) | minimum |
MAX(x) | maksimum |
Jeśli w SELECT nie ma GROUP BY, agregat zwija całą tabelę (albo to, co zostało po WHERE) do jednego wiersza.
SELECT COUNT(*) AS zamowien, SUM(total_amount) AS przychod
FROM orders
WHERE status = 'paid';
Tutaj WHERE najpierw zostawia tylko opłacone zamówienia, a dopiero potem agregaty liczą wynik na tym zbiorze. Dostajesz więc nie surową listę, lecz krótkie podsumowanie — pierwsze uderzenie pulsu „Kotomarketu”.
Ważne: nie wolno mieszać w jednym SELECT agregatu ze zwykłą kolumną bez GROUP BY. Zapytanie SELECT status, COUNT(*) FROM orders zakończy się błędem — SQL nie będzie zgadywał, który status pokazać obok ogólnej liczby.

До этого момента ты в основном доставал строки.
Например:
SELECT id, user_id, status, total_amount
FROM orders
WHERE status = 'paid';
Такой запрос показывает оплаченные заказы один за другим:
| id | user_id | status | total_amount |
|---|---|---|---|
| 101 | 7 | paid | 1200 |
| 102 | 9 | paid | 3500 |
| 103 | 7 | paid | 800 |
| 104 | 12 | paid | 2100 |
Это полезно, если нужно посмотреть сами строки.
Но часто бизнесовый вопрос звучит иначе:
Сколько было оплаченных заказов?
Какая общая выручка?
Какой средний чек?
Какой был минимальный и максимальный чек?
Сколько разных покупателей сделали заказ?
На такие вопросы не нужен длинный список строк. Нужна сводка.
Для этого в 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_count | revenue | avg_order_amount | min_order_amount | max_order_amount |
|---|---|---|---|---|
| 4 | 7600 | 1900 | 800 | 3500 |
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) zaokrągla średnią do groszy.| всего_заказов | покупателей | выручка | средний_чек | минимум | максимум |
|---|---|---|---|---|---|
| 86 | 35 | 1381230.00 | 16060.81 | 890.00 | 42110.00 |
COUNT(*) i COUNT(DISTINCT col) odpowiadają na różne pytania. COUNT(*) liczy wiersze, a COUNT(DISTINCT user_id) — ilu różnych kupujących za nimi stoi. Jeśli zamówień jest więcej niż kupujących, to nie usterka archiwum: ktoś wracał i kupował ponownie.
COUNT(*), COUNT(col), COUNT(DISTINCT col)
У COUNT есть несколько форм, и они отвечают на разные вопросы.
Разберём на маленьком примере.
Пусть есть таблица visits:
| id | promo_code |
|---|---|
| 1 | SALE |
| 2 | SALE |
| 3 | |
| 4 | VIP |
| 5 | NULL |
Запрос:
SELECT
COUNT(*) AS rows_count,
COUNT(promo_code) AS with_promo_code,
COUNT(DISTINCT promo_code) AS different_promo_codes
FROM visits;
вернёт:
| rows_count | with_promo_code | different_promo_codes |
|---|---|---|
| 5 | 3 | 2 |
Почему так?
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_count | revenue | avg_order_amount | min_order_amount | max_order_amount |
|---|---|---|---|---|
| 0 | NULL | NULL | NULL |
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 — это обычный столбец. В таблице у разных заказов разные статусы:
| id | status |
|---|---|
| 1 | paid |
| 2 | pending |
| 3 | paid |
| 4 | cancelled |
Если весь набор строк схлопнулся в одну строку результата, какой 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;
Тогда результат будет не одна общая строка, а отдельная строка на каждый статус:
| status | orders_count |
|---|---|
| paid | 80 |
| pending | 25 |
| cancelled | 18 |
| refunded | 5 |
GROUP BY подробно разберём дальше. Пока важно понять саму причину ошибки:
схлопывает строки, а обычный столбец остаётся многозначным. SQL не выбирает одно значение за тебя.
Главное правило без GROUP BY
Если в запросе нет GROUP BY, агрегатные функции считают итог по всему набору строк, который остался после WHERE.
SELECT COUNT(*), SUM(total_amount)
FROM orders
WHERE status = 'paid';
Такой запрос вернёт одну строку.
Обычный столбец рядом с агрегатом без группировки писать нельзя:
SELECT status, COUNT(*)
FROM orders;
Такой запрос некорректен, потому что status может быть разным у разных строк.
Interview question
Pytanie z rozmowy kwalifikacyjnej: W tabeli jest sto wierszy, a w kolumnie manager_id część wartości to NULL. Co zwrócą COUNT(*), COUNT(manager_id) i COUNT(DISTINCT manager_id) i dlaczego liczby się różnią?
Mocna odpowiedź: COUNT(*) zwróci 100 — liczy wiersze i nie zwraca uwagi na NULL. COUNT(manager_id) zwróci mniej: tylko wiersze z wypełnionym manager_id, bo agregaty ignorują NULL. COUNT(DISTINCT manager_id) zwróci jeszcze mniej albo tyle samo: liczbę różnych niepustych wartości. To trzy różne pytania do danych: „ile jest wierszy”, „w ilu wierszach pole jest wypełnione”, „ile jest różnych wartości”.
Pytanie z rozmowy kwalifikacyjnej: Dlaczego SELECT status, COUNT(*) FROM orders bez GROUP BY to błąd?
Mocna odpowiedź: Agregat zwija wynik do jednego wiersza, a status pozostaje wielowartościowy — w różnych wierszach może być różny, więc SQL nie zgaduje, którą wartość pokazać. Albo usuń status, albo dodaj GROUP BY status, a licznik policzy się osobno dla każdego statusu.
SELECT AVG(price) FROM products;, если GROUP BY не указан?COUNT(*)?COUNT(user_id) отличается от COUNT(DISTINCT user_id)?SELECT status, COUNT(*) FROM orders; без GROUP BY некорректен?КВЕРИ: Сырые строки показывают события. Агрегаты показывают пульс: сколько, на какую сумму, в каких границах и с каким средним ритмом.