В чём разница между 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 раз из-за дискового ввода-вывода. - Фильтр в
WHEREуменьшает число строк, поступающих на входHashAggregate, удерживая операцию в оперативной памяти.
- Планировщик строит в оперативной памяти хэш-таблицу, где ключом является группа (
- 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 → бизнес-кейсы и шаблоны применения
- Что такое оконные функции (Window Functions) → альтернатива GROUP BY с сохранением детализации строк
- Что такое explain plan → поиск Filter Push-down и анализ Buffers/Disk Spill