GROUP BY, HAVING и WHERE: порядок выполнения запроса

Второй обязательный SQL-вопрос: чем WHERE отличается от HAVING и почему это не «одно и то же, но для агрегатов».

GROUP BY схлопывает множество строк в одну по значению ключа. WHERE работает до схлопывания, HAVINGпосле.

Вопрос 1. В чём разница между WHERE и HAVING?

Что проверяет интервьюер. Понимаете ли вы порядок выполнения запроса. Аналитик, который держит его в голове, не пишет заведомо неработающих конструкций и умеет объяснить, почему в WHERE нельзя использовать алиас из SELECT.

Логический порядок обработки такой:

FROM / JOIN  →  WHERE  →  GROUP BY  →  HAVING  →  SELECT  →  ORDER BY  →  LIMIT

Отсюда сразу следуют три вывода: WHERE не видит агрегатов (их ещё не посчитали), HAVING видит, а алиасы из SELECT не видны ни в WHERE, ни в GROUP BY, зато видны в ORDER BY.

CREATE TABLE sales (id INTEGER PRIMARY KEY AUTOINCREMENT, city TEXT, manager TEXT, amount INTEGER, dt TEXT);
INSERT INTO sales (city, manager, amount, dt) VALUES
 ('Москва','Анна',1500,'2026-03-01'),
 ('Москва','Анна',2500,'2026-03-04'),
 ('Москва','Борис',800,'2026-03-02'),
 ('Казань','Вера',1200,'2026-03-03'),
 ('Казань','Глеб',400,'2026-03-05'),
 ('Пермь','Дина',300,'2026-02-27');

SELECT city, SUM(amount) AS total, COUNT(*) AS deals
FROM sales
WHERE dt >= '2026-03-01'
GROUP BY city
ORDER BY total DESC;

Вывод:

Москва  4800  3
Казань  1600  2

Пермь исчезла ещё до группировки: её единственная сделка от 27 февраля отсеялась в WHERE. Теперь добавим порог на уже посчитанную сумму:

CREATE TABLE sales (id INTEGER PRIMARY KEY AUTOINCREMENT, city TEXT, manager TEXT, amount INTEGER, dt TEXT);
INSERT INTO sales (city, manager, amount, dt) VALUES
 ('Москва','Анна',1500,'2026-03-01'),
 ('Москва','Анна',2500,'2026-03-04'),
 ('Москва','Борис',800,'2026-03-02'),
 ('Казань','Вера',1200,'2026-03-03'),
 ('Казань','Глеб',400,'2026-03-05'),
 ('Пермь','Дина',300,'2026-02-27');

SELECT city, SUM(amount) AS total
FROM sales
WHERE dt >= '2026-03-01'
GROUP BY city
HAVING SUM(amount) > 2000
ORDER BY total DESC;

Вывод:

Москва  4800

Казань отсеялась уже после группировки — её сумма не дотянула до порога. Записать это условие в WHERE невозможно: на момент WHERE суммы ещё не существует.

Практическое правило

УсловиеКуда писать
по значению строки (дата, город, статус)WHERE
по результату агрегата (SUM, COUNT, AVG)HAVING
и то и другоеоба — и это правильно

Есть и вопрос производительности: WHERE отсекает строки до группировки, значит группировать придётся меньше данных. Поэтому фильтр, который можно записать в WHERE, туда и пишут — HAVING оставляют только для агрегатов.

Вопрос 2. Что можно класть в SELECT при GROUP BY?

Только то, что перечислено в GROUP BY, и агрегатные функции. Всё остальное — либо ошибка (PostgreSQL, MS SQL), либо, что хуже, случайное значение из группы (MySQL в нестрогом режиме, SQLite). Второе опаснее: запрос отработает, отчёт уедет заказчику, а число будет взято «с потолка».

-- PostgreSQL: ошибка "column sales.manager must appear in the GROUP BY clause"
SELECT city, manager, SUM(amount)
FROM sales
GROUP BY city;

-- корректно: либо добавить в GROUP BY, либо агрегировать
SELECT city, MAX(manager) AS any_manager, SUM(amount)
FROM sales
GROUP BY city;

Вопрос 3. Как ведут себя агрегаты с NULL?

Это любимое продолжение вопроса про GROUP BY. Правило: все агрегаты, кроме COUNT(*), игнорируют NULL. И AVG делит не на общее число строк, а на число не-NULL значений.

CREATE TABLE checks (id INTEGER PRIMARY KEY, amount REAL);
INSERT INTO checks VALUES (1, 100), (2, 200), (3, NULL), (4, 300);

SELECT
  COUNT(*)          AS rows_total,
  COUNT(amount)     AS not_null,
  SUM(amount)       AS total,
  AVG(amount)       AS avg_ignoring_null,
  SUM(amount)/COUNT(*) AS avg_over_all
FROM checks;

Вывод:

4  3  600.0  200.0  150.0

Два «средних» отличаются в полтора раза. Какое из них правильное — вопрос не к SQL, а к бизнесу: NULL здесь означает «данных нет» или «было ноль»? Если «ноль» — считать надо через COALESCE(amount, 0). Умение задать этот вопрос вслух на собеседовании ценится выше, чем знание синтаксиса.

Типичные ошибки кандидатов

  • Отвечают «HAVING — это WHERE для GROUP BY» и останавливаются. Формально близко, но не объясняет главного — порядка выполнения.
  • Пишут WHERE SUM(amount) > 1000 и удивляются ошибке.
  • Складывают всё в HAVING, включая фильтр по дате: запрос работает, но группирует лишние данные.
  • Забывают, что AVG не учитывает NULL, и получают завышенное среднее.
  • Не могут объяснить, почему SELECT city, manager … GROUP BY city в одной СУБД падает, а в другой молча врёт.

Как ответить кратко

«Запрос выполняется в порядке FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. Поэтому WHERE фильтрует отдельные строки до группировки и агрегатов не видит, а HAVING фильтрует уже готовые группы по значению агрегата. Фильтр по дате или городу всегда в WHERE — так группируется меньше строк; порог по SUM или COUNT — в HAVING. И помню, что все агрегаты, кроме COUNT(*), пропускают NULL, поэтому AVG может неожиданно завысить результат».

Проверьте себя
1. В каком порядке СУБД логически обрабатывает части запроса?
ASELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY
BFROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY
CFROM → GROUP BY → WHERE → SELECT → HAVING → ORDER BY
DWHERE → FROM → SELECT → GROUP BY → HAVING → ORDER BY
2. В таблице 4 строки, в одной из них amount = NULL, остальные — 100, 200 и 300. Что вернёт AVG(amount)?
A150.0 — сумма делится на все 4 строки
B200.0 — NULL игнорируется, сумма делится на 3 значения
CNULL — любая операция с NULL даёт NULL
D0.0
3. Куда правильнее поместить фильтр по дате в запросе с GROUP BY и агрегатами?
AВ HAVING — там собраны все фильтры отчёта
BВ WHERE — он отсечёт строки до группировки, и группировать придётся меньше данных
CРазницы нет: оптимизатор всегда переносит условие сам
DВ ORDER BY через CASE