GROUP BY, підзапити та CASE у SQL: практичний гайд
Ви вже вмієте вибирати рядки через SELECT і поєднувати таблиці через JOIN. Але бізнес рідко питає «покажи всі замовлення» — частіше він питає «скільки ми заробили по містах?», «які категорії продаються найкраще?», «хто наші топ-клієнти?». Щоб відповідати на такі запитання, потрібні агрегатні функції та GROUP BY.

Це урок 3 безкоштовного курсу аналітики даних від SEOWORK. У ньому ви навчитеся рахувати суми, середні та кількості по групах, фільтрувати групи через HAVING, вкладати запити один в одного (підзапити) та створювати умовні колонки через CASE WHEN. Наприкінці — експорт результату в CSV, щоб передати дані колегам або завантажити в Power BI.
Усі приклади — на MySQL і нашій демо-базі магазину: таблиці orders, customers, products та order_items. Якщо ви пропустили попередні уроки, почніть з основ SQL.
Агрегатні функції: COUNT, SUM, AVG, MIN, MAX
Агрегатна функція бере багато рядків і повертає одне число. У SQL їх п’ять основних, і ними закривається 90% типових аналітичних задач.
| Функція | Що робить | Приклад для магазину |
|---|---|---|
COUNT(*) | Рахує кількість рядків | Скільки всього замовлень |
SUM(amount) | Сума значень колонки | Загальний дохід |
AVG(amount) | Середнє значення | Середній чек |
MIN(order_date) | Мінімальне значення | Дата першого замовлення |
MAX(amount) | Максимальне значення | Найбільше замовлення |
Без GROUP BY агрегатна функція згортає всю таблицю в один рядок:
SELECT
COUNT(*) AS orders_total,
SUM(amount) AS revenue,
AVG(amount) AS avg_check
FROM orders
WHERE status = 'completed';Зверніть увагу: COUNT(*) рахує всі рядки, а COUNT(колонка) — лише рядки, де колонка не NULL. Ще є COUNT(DISTINCT customer_id) — кількість унікальних покупців. Це три різні числа, і плутати їх не можна.
GROUP BY: розрізаємо дані на групи
GROUP BY ділить рядки на групи за значенням колонки, а агрегатна функція рахується окремо для кожної групи. Класична задача — дохід по статусах замовлень:
SELECT
status,
COUNT(*) AS orders_cnt,
SUM(amount) AS revenue
FROM orders
GROUP BY status
ORDER BY revenue DESC;Головне правило: у SELECT можуть бути лише колонки з GROUP BY та агрегатні функції. Якщо додати «сирі» колонки, MySQL у режимі ONLY_FULL_GROUP_BY поверне помилку — і це правильно, бо СУБД не знає, яке саме значення з групи показати.
GROUP BY разом із JOIN
Найчастіше групування працює в парі з поєднанням таблиць. Порахуємо дохід по містах клієнтів:
SELECT
c.city,
COUNT(DISTINCT o.customer_id) AS buyers,
SUM(o.amount) AS revenue
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'completed'
GROUP BY c.city
ORDER BY revenue DESC;А ось продажі по категоріях товарів — тут знадобляться вже три таблиці:
SELECT
p.category,
SUM(oi.qty) AS units_sold,
SUM(oi.qty * p.price) AS revenue
FROM order_items oi
JOIN products p ON p.id = oi.product_id
GROUP BY p.category
ORDER BY revenue DESC;HAVING: фільтр для груп
WHERE фільтрує окремі рядки до групування, а HAVING — уже готові групи після нього. У HAVING можна використовувати агрегатні функції, у WHERE — ні.
Знайдемо міста, де сумарний дохід перевищує 50 000 грн:
SELECT
c.city,
SUM(o.amount) AS revenue
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'completed' -- фільтр рядків ДО групування
GROUP BY c.city
HAVING SUM(o.amount) > 50000 -- фільтр груп ПІСЛЯ групування
ORDER BY revenue DESC;Порядок виконання запиту в СУБД такий: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. Саме тому в WHERE не можна звертатися до результату агрегації — на той момент її ще не існує.
Підзапити: запит усередині запиту
Підзапит (subquery) — це SELECT, вкладений в інший запит. Він виконується першим, а його результат використовує зовнішній запит. Розглянемо три місця, де підзапити застосовують найчастіше.
Підзапит у WHERE
Знайдемо замовлення, більші за середній чек:
SELECT id, customer_id, amount
FROM orders
WHERE amount > (SELECT AVG(amount) FROM orders)
ORDER BY amount DESC;Підзапит повертає одне число — середній чек, — і зовнішній запит порівнює з ним кожен рядок. Якщо підзапит повертає список значень, використовуйте IN. Наприклад, клієнти, які купували товари з категорії «Електроніка»:
SELECT name, city
FROM customers
WHERE id IN (
SELECT o.customer_id
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE p.category = 'Електроніка'
);Підзапит у FROM
Підзапит у FROM створює тимчасову «віртуальну таблицю» (derived table), з якою далі можна працювати як зі звичайною. Порахуємо середню кількість замовлень на одного клієнта:
SELECT AVG(orders_cnt) AS avg_orders_per_customer
FROM (
SELECT customer_id, COUNT(*) AS orders_cnt
FROM orders
GROUP BY customer_id
) AS t;У MySQL підзапит у FROM обов’язково має отримати псевдонім (тут — AS t), інакше буде помилка.
Підзапит у SELECT
Скалярний підзапит у SELECT додає обчислену колонку до кожного рядка. Виведемо клієнтів разом із кількістю їхніх замовлень:
SELECT
c.name,
c.city,
(SELECT COUNT(*)
FROM orders o
WHERE o.customer_id = c.id) AS orders_cnt
FROM customers c;Це корельований підзапит: він виконується для кожного рядка зовнішнього запиту. На великих таблицях таке рішення повільне — зазвичай його краще переписати через JOIN + GROUP BY. Але для швидкої розвідки даних воно цілком доречне.
CASE WHEN: умовна логіка в запиті
CASE — це «if-else» у SQL. Він перевіряє умови по черзі та повертає значення першої, що спрацювала. Сегментуємо замовлення за розміром чека:
SELECT
id,
amount,
CASE
WHEN amount >= 5000 THEN 'великий'
WHEN amount >= 1000 THEN 'середній'
ELSE 'малий'
END AS order_size
FROM orders
WHERE status = 'completed';Якщо жодна умова не виконалась і блоку ELSE немає, CASE поверне NULL. Умови перевіряються зверху вниз, тому ставте найвужчу умову першою.
CASE всередині агрегатних функцій
Найпотужніший прийом цього уроку — комбінація CASE з SUM або COUNT. Так будують зведені таблиці (pivot) прямо в SQL. Порахуємо для кожного міста кількість виконаних і скасованих замовлень в окремих колонках:
SELECT
c.city,
COUNT(CASE WHEN o.status = 'completed' THEN 1 END) AS completed_cnt,
COUNT(CASE WHEN o.status = 'cancelled' THEN 1 END) AS cancelled_cnt,
SUM(CASE WHEN o.status = 'completed' THEN o.amount ELSE 0 END) AS revenue
FROM orders o
JOIN customers c ON c.id = o.customer_id
GROUP BY c.city;Хитрість у тому, що COUNT ігнорує NULL: коли умова CASE не виконується, рядок просто не потрапляє в підрахунок. Один запит — і готовий міні-звіт для керівника.
Експорт результату в CSV
Результат запиту часто потрібно віддати далі — в Excel, Google Sheets або Power BI. Є два робочі способи.
Спосіб 1: через клієнт. У MySQL Workbench виконайте запит і натисніть кнопку Export над таблицею результатів — оберіть формат CSV. У DBeaver аналогічно: правий клік по результату → Export resultset. Це найпростіший шлях для початківця.
Спосіб 2: через SQL. Команда SELECT ... INTO OUTFILE зберігає результат у файл на сервері БД:
SELECT c.city, SUM(o.amount) AS revenue
FROM orders o
JOIN customers c ON c.id = o.customer_id
GROUP BY c.city
INTO OUTFILE '/var/lib/mysql-files/revenue_by_city.csv'
FIELDS TERMINATED BY ','
ENCLOSED BY '"'
LINES TERMINATED BY '\n';Для цього способу потрібен привілей FILE, а шлях має бути дозволений налаштуванням secure_file_priv. Якщо сервер віддалений і доступу до його файлової системи немає — користуйтеся експортом через клієнт.
Поширені помилки
- «Сирі» колонки в SELECT без GROUP BY. Запит
SELECT city, name, SUM(amount)... GROUP BY cityнекоректний: у групі багато різнихname. Додайте колонку вGROUP BYабо приберіть ізSELECT. - Агрегат у WHERE замість HAVING.
WHERE SUM(amount) > 1000завжди дає помилку. Фільтруєте групи — пишітьHAVING. - Плутанина COUNT(*) і COUNT(колонка). Якщо в колонці є
NULL, результати відрізнятимуться. Для «кількості рядків» берітьCOUNT(*), для унікальних значень —COUNT(DISTINCT ...). - = замість IN із підзапитом. Якщо підзапит повертає кілька рядків, порівняння через
=впаде з помилкою «Subquery returns more than 1 row». ВикористовуйтеIN. - Агрегація після JOIN «один до багатьох» без перевірки. JOIN може розмножити рядки, і
SUMпорахує суми по кілька разів. Перевіряйте кількість рядків до і після поєднання — детально це розібрано в уроці про JOIN. - CASE без ELSE у SUM.
SUM(CASE WHEN ... THEN amount END)повернеNULL, якщо жоден рядок не підійшов. Для сум безпечніше додаватиELSE 0.
FAQ
Чим HAVING відрізняється від WHERE?
WHERE відсіює окремі рядки до групування і не може містити агрегатних функцій. HAVING працює після GROUP BY і фільтрує вже готові групи за результатами агрегації. У одному запиті вони чудово поєднуються: спершу WHERE звужує дані, потім HAVING відбирає групи.
Що швидше: підзапит чи JOIN?
Універсальної відповіді немає — сучасний оптимізатор MySQL часто перетворює підзапити на з’єднання автоматично. На практиці корельовані підзапити в SELECT найчастіше програють еквівалентному JOIN + GROUP BY. Пишіть спочатку зрозуміло, а оптимізуйте тоді, коли запит реально гальмує: команда EXPLAIN покаже план виконання.
Чи можна групувати за кількома колонками?
Так: GROUP BY c.city, p.category створить окрему групу для кожної комбінації «місто + категорія». Порядок колонок у GROUP BY на результат не впливає, лише на зручність читання.
Скільки рівнів вкладеності підзапитів допустимо?
Технічно MySQL дозволяє десятки рівнів, але вже на третьому запит стає нечитабельним. Якщо вкладеність росте, винесіть проміжні кроки в CTE через WITH (доступно з MySQL 8.0) — логіка та сама, а код значно чистіший.
Що далі
Ви навчилися відповідати на головні бізнес-запитання: скільки, у середньому, по яких групах і за якими умовами. Разом із SELECT і JOIN це вже повноцінний інструментарій junior-аналітика для щоденної роботи з даними.
- Наступний урок: Віконні функції SQL: ROW_NUMBER, RANK, LAG з прикладами — навчіться ранжувати рядки та рахувати накопичувальні підсумки без згортання даних.
- Попередній урок: SQL JOIN: як поєднати таблиці — INNER, LEFT, RIGHT — якщо JOIN у прикладах вище викликали питання.
- Уся програма: курс аналітики даних з нуля — 9 безкоштовних уроків від SQL до Power BI.
Це урок 3 з 9 курсу аналітики даних.
Уроки виходять щодня о 09:30 — програма і навігація на сторінці курсу.