У чому різниця між WHERE та HAVING?
Уявіть інспекцію ящиків з яблуками в саду:
🟢 Junior Level
WHERE та HAVING — це ключові слова SQL для фільтрації даних, але вони працюють із принципово різними об’єктами та на різних етапах виконання запиту:
- WHERE — фільтрує початкові рядки таблиці ДО того, як вони об’єднуються в групи.
- HAVING — фільтрує вже сформовані групи ПІСЛЯ виконання агрегації (
GROUP BY).
graph LR
A["Таблиця (Рядки)"] --> B["WHERE (Фільтрація рядків)"]
B --> C["GROUP BY (Групування)"]
C --> D["HAVING (Фільтрація груп)"]
D --> E["SELECT / Виведення"]
Наочна аналогія
Уявіть інспекцію ящиків з яблуками в саду:
- WHERE: Спочатку ви перебираєте всі яблука поштучно і відкидаєте гнилі (
WHERE quality = 'GOOD'). - GROUP BY: Решту якісних яблук розкладаєте по ящиках відповідно до сорту (
GROUP BY apple_variety). - HAVING: Ви зважуєте кожен отриманий ящик і відправляєте на склад лише ті ящики, де сумарна вага перевищує 50 кг (
HAVING SUM(weight) > 50).
Базовий приклад
-- WHERE відсікає скасовані замовлення до агрегації
-- HAVING залишає лише клієнтів із сумарним чеком понад 10 000 ₴
SELECT
user_id,
COUNT(*) AS total_orders,
SUM(amount) AS total_spent
FROM orders
WHERE status != 'CANCELLED' -- 1. Фільтр окремих рядків (до групування)
GROUP BY user_id -- 2. Об'єднання в групи за користувачами
HAVING SUM(amount) > 10000; -- 3. Фільтр груп за значенням агрегату
Просте мнемонічне правило
- Потрібно відфільтрувати дані за значенням стовпця (
id,created_at,status) → використовуйте WHERE. - Потрібно відфільтрувати за результатом агрегатної функції (
COUNT,SUM,AVG,MIN,MAX) → використовуйте HAVING. - В умові WHERE агрегатні функції використовувати заборонено (СУБД видасть синтаксичну помилку
aggregate functions are not allowed in WHERE).
🟡 Middle Level
Логічний порядок виконання SQL-запиту (Logical Query Processing)
Щоб безпомилково розуміти поведінку СУБД, необхідно пам’ятати, що запит виконується не в тому порядку, у якому він написаний:
SELECT user_id, COUNT(*) AS orders_count -- 5. SELECT (формування проекції)
FROM orders -- 1. FROM (визначення джерела)
WHERE status = 'PAID' -- 2. WHERE (фільтрація рядків, робота індексів)
GROUP BY user_id -- 3. GROUP BY (формування груп)
HAVING COUNT(*) >= 5 -- 4. HAVING (фільтрація груп)
ORDER BY orders_count DESC -- 6. ORDER BY (сортування виведення)
LIMIT 10; -- 7. LIMIT (усічення результату)
Чому агрегати заборонені у WHERE:
На кроці 2 (WHERE) групи рядків ще фізично не сформовані, а проміжні акумулятори агрегатних значень (COUNT, SUM) ще не ініціалізовані. СУБД читає рядки по одному і перевіряє істинність предиката.
Вплив на продуктивність та план виконання
Найпоширеніша помилка — розміщення неагрегатних умов у блоці HAVING:
-- ❌ КАТАСТРОФА: неагрегатний предикат у HAVING
SELECT department_id, AVG(salary)
FROM employees
GROUP BY department_id
HAVING department_id = 42;
-- СУБД змушена прочитати та згрупувати мільйони рядків УСІХ відділів,
-- а потім відкинути всі групи, крім відділу 42!
-- ✅ ЕТАЛОН: фільтрація у WHERE
SELECT department_id, AVG(salary)
FROM employees
WHERE department_id = 42 -- Індекс за department_id відсікає зайве ДО групування
GROUP BY department_id;
-- СУБД читає та групує лише рядки відділу 42.
Порівняльна таблиця WHERE vs HAVING
| Критерій | WHERE |
HAVING |
|---|---|---|
| Об’єкт фільтрації | Окремі рядки (Row-level) | Агреговані групи (Group-level) |
| Етап обчислення | До GROUP BY |
Після GROUP BY |
| Використання індексів | ✅ Ефективно (Index Scan / Index Only Scan) | ❌ Ні (фільтрує агрегований потік) |
| Агрегатні функції | ❌ Заборонені (COUNT, SUM викличуть помилку) |
✅ Дозволені та рекомендовані |
| Вплив на пам’ять | Знижує навантаження на work_mem |
Не зменшує обсяг рядків у хеш-таблиці агрегації |
| Робота без GROUP BY | ✅ Стандартний сценарій | ✅ Допустимо (вся таблиця вважається однією групою) |
Конструкція HAVING без GROUP BY
Якщо вказати HAVING без явного GROUP BY, уся таблиця неявно вважається однією групою:
-- Запит поверне один рядок, якщо в таблиці понад 100 000 користувачів, інакше 0 рядків
SELECT 'High traffic system' AS system_flag
FROM users
HAVING COUNT(*) > 100000;
Конструкція FILTER (WHERE …) — сучасна альтернатива в PostgreSQL
Починаючи з SQL:2003 та в актуальних версіях PostgreSQL для умовної агрегації рекомендується використовувати конструкцію FILTER (WHERE ...) замість роздування умов HAVING чи використання громіздких конструкцій CASE WHEN:
-- Підрахунок різних метрик у межах одного запиту:
SELECT
department_id,
COUNT(*) AS total_employees,
COUNT(*) FILTER (WHERE salary > 100000) AS high_earners,
AVG(salary) FILTER (WHERE hire_date >= '2023-01-01') AS new_hires_avg_salary
FROM employees
GROUP BY department_id
HAVING COUNT(*) >= 5; -- HAVING фільтрує всю групу, FILTER — конкретний агрегат!
Проекція в Java, Spring Data JPA та Hibernate
У backend-розробці на Java інженери часто стикаються з WHERE та HAVING у JPQL/HQL та Criteria API:
// Приклад JPQL з WHERE та HAVING:
@Query("""
SELECT o.userId, COUNT(o.id), SUM(o.amount)
FROM Order o
WHERE o.status = :status
GROUP BY o.userId
HAVING SUM(o.amount) > :minSpend
""")
List<UserOrderStats> findTopSpenders(
@Param("status") OrderStatus status,
@Param("minSpend") BigDecimal minSpend
);
Критична помилка junior-розробників (In-Memory Processing):
Вивантажити всі 500 000 замовлень у пам’ять застосунку через findAll() і спробувати зімітувати SQL-агрегацію за допомогою Java Stream API:
// ❌ АНТИПАТЕРН: перевантаження пам'яті JVM (OutOfMemoryError) та бази даних
List<Order> orders = orderRepository.findAll();
Map<Long, Double> userSpending = orders.stream()
.filter(o -> o.getStatus() == OrderStatus.PAID) // Аналог WHERE
.collect(Collectors.groupingBy(
Order::getUserId,
Collectors.summingDouble(Order::getAmount)
))
.entrySet().stream()
.filter(entry -> entry.getValue() > 10000.0) // Аналог HAVING
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
// База даних виконує це за мілісекунди з використанням індексів і без перекачування гігабайтів мережею!
🔴 Senior Level
Фізичні оператори агрегації в PostgreSQL та навантаження на work_mem
PostgreSQL виконує агрегацію одним із двох фізичних методів:
- HashAggregate:
- Планувальник будує в оперативній пам’яті хеш-таблицю, де ключем є група (
GROUP BY key), а значеннями — акумулятори агрегатів. - Розмір виділеної пам’яті на одну операцію обмежений параметром
work_mem(за замовчуванням 4 МБ). - Небезпека:
HAVINGне рятує від переповнення пам’яті! До хеш-таблиці потрапляють усі рядки, які пройшли фільтрWHERE. Якщо обсяг агрегованих даних перевищуєwork_mem, PostgreSQL скидає розділи хеш-таблиці на диск (spill to disk) у тимчасові файли (temp_files). Це призводить до деградації швидкодії в 10–100 разів через дискове введення-виведення (I/O). - Фільтр у
WHEREзменшує кількість рядків, що надходять на вхідHashAggregate, утримуючи операцію в швидкій RAM.
- Планувальник будує в оперативній пам’яті хеш-таблицю, де ключем є група (
- GroupAggregate:
- Вимагає попередньо відсортованого потоку даних (через явний вузол
Sortабо читання з B-tree індексу за стовпцями групування). - Споживає константний обсяг пам’яті $O(1)$ на групу, обчислюючи агрегати потоково.
- Якщо сортування виконується в пам’яті, воно також конкурує за
work_mem.
- Вимагає попередньо відсортованого потоку даних (через явний вузол
Сценарій: 10 000 000 рядків у таблиці logs (work_mem = 4MB)
Варіант 1 (Фільтр у HAVING):
SELECT service_id, COUNT(*)
FROM logs
GROUP BY service_id
HAVING service_id = 'auth-service';
-> HashAggregate: читає 10 000 000 рядків -> Spill to disk (temp_files ~ 180MB) -> Час: 6.8 сек.
Варіант 2 (Фільтр у WHERE):
SELECT service_id, COUNT(*)
FROM logs
WHERE service_id = 'auth-service'
GROUP BY service_id;
-> Index Scan -> HashAggregate: читає 15 000 рядків -> In-Memory (Memory: 64kB) -> Час: 8 мс.
Механізм Filter Push-Down та його обмеження
Починаючи з PostgreSQL 12+, оптимізатор запитів реалізує евристику Filter Push-Down: якщо в секцію HAVING помилково поміщено умову, яка не залежить від агрегатних функцій, оптимізатор спробує автоматично «проштовхнути» її вниз — у секцію WHERE.
-- Початковий запит
SELECT category_id, COUNT(*)
FROM products
GROUP BY category_id
HAVING category_id > 100 AND COUNT(*) > 5;
-- План виконання (EXPLAIN):
-- Filter (Push-down): (category_id > 100) переміщується у вузол Seq Scan / Index Scan
-- На рівні Aggregates залишається тільки: (COUNT(*) > 5)
Коли Filter Push-Down гарантовано НЕ спрацює:
- Волатильні функції (Volatile Functions):
-- random() волатильний, оптимізатор не має права змінювати порядок його виклику SELECT category_id, COUNT(*) FROM products GROUP BY category_id HAVING category_id > random() * 100; - CTE з модифікатором MATERIALIZED:
Обмежувальний бар’єр оптимізації (Optimization Fence) уWITH cte AS MATERIALIZED (...)перешкоджає наскрізному перенесенню умов. - Складні представлення (Views) з UNION/GROUP BY:
Якщо умова накладена зовні view із групуванням, СУБД не завжди може проштовхнути предикат усередину агрегату. - Аліаси та скалярні підзапити в HAVING:
Підзапити всередині HAVING виконуються на рівні груп.
[!IMPORTANT] Наявність оптимізатора не знімає відповідальності з інженера: покладатися на Filter Push-Down заборонено правилами написання продакшн-запитів. Неагрегатні умови зобов’язані перебувати строго у
WHERE.
Чому Window Functions несумісні з WHERE та HAVING
Віконні функції (ROW_NUMBER(), RANK(), SUM() OVER(...)) обчислюються на кроці WindowAgg, який у логічному конвеєрі розташований строго після HAVING і безпосередньо перед SELECT.
Логічний конвеєр:
FROM -> WHERE -> GROUP BY -> HAVING -> WindowAgg -> SELECT -> DISTINCT -> ORDER BY -> LIMIT
З цієї причини:
- Не можна відфільтрувати віконну функцію у
WHERE(вона ще не існує). - Не можна відфільтрувати віконну функцію в
HAVING(HAVING працює з групамиGROUP BY, а не вікнамиPARTITION BY).
-- ❌ СИНТАКСИЧНА ПОМИЛКА: window functions are not allowed in HAVING
SELECT department_id, salary,
ROW_NUMBER() OVER(PARTITION BY department_id ORDER BY salary DESC) as rn
FROM employees
HAVING ROW_NUMBER() OVER(PARTITION BY department_id ORDER BY salary DESC) <= 3;
-- ✅ ЕТАЛОННЕ РІШЕННЯ: Обгортання в CTE або підзапит
WITH RankedEmployees AS (
SELECT department_id, salary,
ROW_NUMBER() OVER(PARTITION BY department_id ORDER BY salary DESC) AS rn
FROM employees
)
SELECT department_id, salary, rn
FROM RankedEmployees
WHERE rn <= 3; -- Зовнішній WHERE фільтрує готові рядки вікна
Архітектура складених індексів під WHERE + GROUP BY
Для запитів, що містять одночасно WHERE та GROUP BY, вирішальне значення має порядок стовпців у складеному індексі (B-tree Composite Index):
SELECT tenant_id, status, COUNT(*)
FROM payments
WHERE tenant_id = 'tenant-42'
GROUP BY status;
Аналіз варіантів індексу:
- Індекс
(tenant_id, status):- Планувальник виконує
Index Scanточно до гілкиtenant_id = 'tenant-42'. - Усередині цього піддерева дані вже відсортовані за
status. - PostgreSQL може застосувати швидкий потоковий
GroupAggregateбез додаткового сортування (Sortвузол відсутній у плані!).
- Планувальник виконує
- Індекс
(status, tenant_id):- Планувальнику доведеться сканувати весь індекс або виконувати
Bitmap Index Scan. - Знадобиться явна фаза
Sortперед агрегацією.
- Планувальнику доведеться сканувати весь індекс або виконувати
Аналіз реального плану запиту в EXPLAIN (ANALYZE, BUFFERS)
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, COUNT(*) AS orders_cnt
FROM orders
WHERE created_at >= '2026-01-01'
GROUP BY customer_id
HAVING COUNT(*) > 10;
QUERY PLAN:
HashAggregate (cost=12540.20..12890.50 rows=3503 width=12) (actual time=48.210..52.140 rows=142 loops=1)
Group Key: customer_id
Filter: (count(*) > 10)
Rows Removed by Filter: 8450
Buffers: shared hit=4210 read=180
-> Bitmap Heap Scan on orders (cost=142.10..11200.00 rows=152000 width=4) (actual time=3.120..22.450 rows=151800 loops=1)
Recheck Cond: (created_at >= '2026-01-01'::date)
Buffers: shared hit=890 read=180
-> Bitmap Index Scan on idx_orders_created_at (cost=0.00..104.10 rows=152000 width=0) (actual time=2.100..2.100 rows=151800 loops=1)
Index Cond: (created_at >= '2026-01-01'::date)
Buffers: shared hit=45
Що демонструє план:
Bitmap Index Scan+Bitmap Heap Scan: фільтраціяcreated_atу блоціWHEREвідсікла мільйони застарілих записів на диску за індексом.- У
HashAggregateнадійшли лише 151 800 актуальних рядків. - Вузол
HashAggregateсформував групи та застосував фільтрFilter: (count(*) > 10). МетрикаRows Removed by Filter: 8450показує, скільки груп відкинуто умовоюHAVING.
🎯 Шпаргалка для інтерв’ю
Обов’язково знати (Перші 30 секунд відповіді)
- Фундаментальна різниця:
WHEREфільтрує рядки до агрегації;HAVINGфільтрує групи після агрегації. - Індекси:
WHEREактивно використовує B-tree/BRIN індекси та зменшує обсяг читаних даних;HAVINGперевіряє предикати на виході хеш-таблиці або сортування. - Агрегатні функції: У
WHEREагрегати використовувати заборонено (синтаксична помилка); уHAVINGагрегати — основне призначення. - Порядок виконання:
FROM→WHERE→GROUP BY→HAVING→SELECT→ORDER BY→LIMIT. - Без GROUP BY:
HAVINGможе використовуватися безGROUP BY— у цьому випадку вся таблиця виступає як одна зведена група.
Часті уточнюючі запитання на співбесіді
-
«Що станеться, якщо неагрегатне поле вказати в HAVING замість WHERE?»
Відповідь: У старих версіях СУБД (PostgreSQL < 12, MySQL 5.6) це призводить до колосального падіння продуктивності: СУБД спочатку групує абсолютно всі рядки таблиці, заповнюючи пам’ять (work_mem) або скидаючи дані на диск (temp_files), і лише потім відкидає зайві групи. У сучасних версіях оптимізатор спробує виконати Filter Push-Down, але покладатися на це в продакшені неприпустимо. -
«Чи можна використовувати аліас із секції SELECT усередині WHERE або HAVING?»
Відповідь: У стандартному SQL аліаси ізSELECTнедоступні ні уWHERE, ні вHAVING, оскільки етапSELECTвиконується пізніше за них. Однак PostgreSQL і MySQL як розширення стандарту дозволяють використовувати аліаси виразів уHAVINGтаGROUP BY, але забороняють уWHERE. Для переносимості коду слід повторювати агрегатну функцію вHAVINGабо використовувати CTE/підзапит. -
«У чому різниця між
COUNT(*) FILTER (WHERE status = 'PAID')таHAVING status = 'PAID'?»
Відповідь: УмоваHAVING status = 'PAID'застосовна лише якщоstatusвходить уGROUP BY, і вона відкидає всю групу цілком, якщо умова хибна. КонструкціяFILTER (WHERE ...)обчислює агрегат усередині групи тільки за відповідними рядками, не виключаючи саму групу з підсумкового набору даних. -
«Як відфільтрувати рядки за результатом віконної функції?»
Відповідь: Напряму ні уWHERE, ні вHAVINGце зробити неможливо, оскільки віконні функції (WindowAgg) розраховуються після крокуHAVING. Фільтрація віконних функцій виконується виключно через обгортання запиту в CTE (WITH) або підзапит із подальшим фільтром у зовнішньомуWHERE.
Червоні прапорці (НЕ говорити на інтерв’ю)
- ❌ «HAVING швидший за WHERE, якщо колонка входить до індексу» — грубе нерозуміння. Індекси застосовуються у
WHERE.HAVINGобчислюється над уже сформованими агрегатними слотами. - ❌ «Якщо в запиті є GROUP BY, всі фільтри потрібно писати в HAVING» — фатальна помилка, що призводить до читання всієї таблиці замість вибіркового сканування.
- ❌ «HAVING зменшує розмір використовуваної пам’яті work_mem при агрегації» — ні, рядки потрапляють у структуру HashAggregate до застосування фільтра
HAVING. - ❌ «Віконні функції можна передавати в HAVING, якщо вони ділять рядки за тією ж колонкою, що й GROUP BY» — синтаксис SQL строго забороняє Window Functions у секції HAVING.
Пов’язані теми
- Як працює GROUP BY → механізми агрегації даних та оператори Hash/Group Aggregate
- Коли використовувати HAVING → бізнес-кейси та шаблони застосування
- Що таке віконні функції → альтернатива GROUP BY зі збереженням деталізації рядків
- Що таке explain plan → пошук Filter Push-down та аналіз Buffers/Disk Spill