HAVING против WHERE: фильтр до или после группировки
WHERE фильтрует строки до группировки; HAVING фильтрует целые группы после агрегации. В WHERE агрегат нельзя, а проталкивание построчных фильтров в WHERE — реальный рычаг производительности.
Отчёт по выручке показывает «топ-клиентов с более чем $10k оплаченных заказов». Первый черновик засунул paid и > 10000 в один HAVING и работал 9 секунд, сканируя каждый когда-либо сделанный заказ. Перенос одного предиката на одно предложение вверх — paid в WHERE — уронил время до 200мс, потому что группировке внезапно осталось переваривать в десять раз меньше строк. WHERE против HAVING — это не стиль; это меняет работу движка.
Два фильтра в два разных момента
У агрегации фиксированный конвейер, и два фильтрующих предложения стоят по разные стороны свёртки:
FROM/JOIN— собрать сырые строки.WHERE— отбросить строки, которые не нужны. Работает до группировки, поэтому действует на отдельные строки и не видит ни одного агрегата (групп ещё нет).GROUP BY— свернуть пережившие строки в корзины.HAVING— отбросить целые группы, которые не нужны. Работает после агрегации, поэтому может ссылаться на агрегаты вродеSUM(total)илиCOUNT(*).SELECT/ORDER BY/LIMIT— оформить вывод.
В совокупности эти шаги означают, что у каждого фильтра есть ровно одно верное место: если условие об отдельной строке — шаг 2 (WHERE); если о результате свёртки группы — шаг 4 (HAVING). Поставишь построчный фильтр на шаг 4 — зря оплатишь всю работу группировки по строкам, которые собирался выбросить.
Конкретно — правильный отчёт:
SELECT user_id, SUM(total) AS paid_revenue
FROM orders
WHERE status = 'paid' -- построчное условие → до группировки
GROUP BY user_id
HAVING SUM(total) > 10000; -- условие на агрегат → после группировкиstatus = 'paid' решается на одной строке, поэтому идёт в WHERE и сжимает вход для GROUP BY. SUM(total) > 10000 можно вычислить только когда целая корзина существует, поэтому это обязательно HAVING.
Почему агрегаты в WHERE недопустимы
Попробуй перенести порог в WHERE — и Postgres откажет:
SELECT user_id, SUM(total)
FROM orders
WHERE SUM(total) > 10000 -- ERROR: aggregate functions are not allowed in WHERE
GROUP BY user_id;Это не недостающая фича — это невозможность по времени. WHERE работает до GROUP BY, в точке, где ещё нет ни одной группы, так что SUM(total) нечего суммировать. Агрегат в этот момент не определён. HAVING существует именно как фильтр, работающий после того, как группы (и их агрегаты) материализуются.
Рычаг производительности: проталкивай фильтры вниз
Вот часть, отделяющая корректный запрос от быстрого. Оба возвращают одни и те же строки:
-- МЕДЛЕННО: каждый заказ группируется, потом неоплаченные группы... всё равно неверно и дорого
SELECT user_id, SUM(total)
FROM orders
GROUP BY user_id
HAVING bool_and(status = 'paid'); -- вычурно и сначала группирует ВСЕ заказы
-- БЫСТРО: отбросить неоплаченные строки до того, как они дойдут до группировки
SELECT user_id, SUM(total)
FROM orders
WHERE status = 'paid'
GROUP BY user_id;Построчный фильтр место в WHERE, чтобы до (дорогого) шага группировки доходило меньше строк. Группировке приходится хешировать или сортировать вход; вдвое меньший вход примерно вдвое дешевле, а если есть частичный индекс по status, планировщик может вовсе пропустить несовпадающие строки. Положить простое построчное условие в HAVING — это code smell (признак проблемного кода): обычно это значит, что фильтр, который должен был отработать до группировки, работает после неё, выполняя всю работу группировки над строками, которые ты собирался выбросить.
Подкрепим цифрами. На таблице orders в 12М строк, где paid всего ~1.2М, версия только с HAVING в EXPLAIN ANALYZE выглядит как Seq Scan (rows=12000000), питающий HashAggregate, который строит корзину для каждого пользователя — 9с по часам, причём агрегат спиллит на диск (Disk Usage: 180000 kB), ведь пришлось материализовать все 2М групп пользователей. Перенеси status='paid' в WHERE — и план становится Bitmap Index Scan по частичному индексу WHERE status='paid', возвращающим rows=1200000, который питает HashAggregate из ~400k корзин переживших пользователей, теперь влезающий в work_mem (строки Disk нет) — ~200мс. Те же строки результата, но группировка пережевала в 10 раз меньше входных строк и построила в ~5 раз меньше корзин. Этот рычаг не микрооптимизация: это разница между запросом на 9с и на 0.2с.
▸Частая ошибка
Классическая ловушка: HAVING status = 'paid'. В Postgres это синтаксически законно, только если status функционально зависит от ключа группировки — иначе получишь ту же ошибку «must appear in GROUP BY». Даже когда парсится, это неверный подход: ты сгруппировал каждый заказ, включая те, что отбросишь. Правило большого пальца — если условие не упоминает ни одного агрегата, оно почти всегда место в WHERE. Резервируй HAVING для условий на SUM, COUNT, AVG, MAX, MIN и т.п.
Почему WHERE SUM(total) > 10000 поднимает ошибку?
Тебе нужна оплаченная выручка на пользователя свыше $10k. В какое предложение идёт status='paid' и почему?
Упорядочи предложения по тому, когда Postgres логически их применяет:
- 1 FROM / JOIN — собрать сырые строки
- 2 WHERE — фильтр отдельных строк
- 3 GROUP BY — свернуть строки в корзины
- 4 HAVING — фильтр целых групп через агрегаты
- 5 SELECT — спроецировать выходные колонки
- 6 ORDER BY / LIMIT — отсортировать и обрезать
- 01Одним предложением о каждом: что фильтруют WHERE и HAVING и в каком порядке работают?
- 02Почему WHERE SUM(total) > 10000 это ошибка и в чём фикс?
- 03Почему положить status = 'paid' в HAVING вместо WHERE — это и smell, и проблема производительности?
Конвейер агрегации идёт FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY, и два фильтра стоят по разные стороны свёртки. WHERE фильтрует отдельные строки до группировки и поэтому не может ссылаться на агрегат — групп ещё нет, так что SUM(total) не определён и Postgres его отвергает. HAVING фильтрует целые группы после агрегации и именно туда место агрегатным условиям вроде SUM(total) > 10000. Сеньорская привычка — проталкивать каждый построчный предикат в WHERE, чтобы до дорогого шага группировки доходило меньше строк (вдвое меньший вход примерно вдвое дешевле по hash/sort, и частичные индексы могут отсечь строки), и считать простое неагрегатное условие, застрявшее в HAVING, smell’ом. Дальше углубимся в сами агрегатные функции: чем COUNT(*) отличается от COUNT(col), предложение FILTER и числовые подводные камни AVG и SUM.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.