Запрос пишется в одном порядке, а обрабатывается в другом. Пока запросы простые, это незаметно. Как только появляются агрегаты, оконные функции и фильтры, половина «странных» ошибок и неверных цифр объясняется именно порядком обработки.
Порядок записи и порядок обработки
Пишем мы так: SELECT, FROM, WHERE, GROUP BY, HAVING, ORDER BY, LIMIT. Логически запрос обрабатывается иначе:
| Шаг | Что происходит | Что уже доступно |
|---|---|---|
1. FROM и JOIN | собирается набор строк | колонки таблиц |
2. WHERE | отбрасываются строки | колонки таблиц |
3. GROUP BY | строки сворачиваются в группы | ключи групп |
4. HAVING | отбрасываются группы | ключи групп и агрегаты |
| 5. оконные функции | считаются по тому, что осталось | всё выше |
6. SELECT | вычисляются выражения и алиасы | всё выше |
7. DISTINCT | убираются повторы | результат SELECT |
8. ORDER BY | сортировка | результат SELECT и алиасы |
9. LIMIT и OFFSET | обрезка | отсортированный результат |
Каждый шаг видит только то, что подготовили предыдущие. Из этого следуют все правила ниже.
Алиас из SELECT не виден в WHERE
SELECT order_id, quantity * unit_price AS amount
FROM order_items
WHERE amount > 50000;ERROR: column "amount" does not existWHERE выполняется на шаге 2, а алиас amount появляется на шаге 6. Для фильтра такой колонки ещё не существует. Вариантов три: повторить выражение, вынести вычисление в подзапрос или CTE, либо посчитать его в LATERAL.
-- повторить выражение
SELECT order_id, quantity * unit_price AS amount
FROM order_items
WHERE quantity * unit_price > 50000;
-- вычислить один раз и фильтровать уровнем выше
WITH priced AS (
SELECT order_id, quantity * unit_price AS amount
FROM order_items
)
SELECT * FROM priced WHERE amount > 50000;Оба варианта дают 796 строк. Второй длиннее, но выражение записано один раз: когда формула суммы поменяется, её не придётся искать в трёх местах запроса.
ORDER BY выполняется после SELECT, поэтому алиасы в нём работают. PostgreSQL разрешает алиас и в GROUP BY, но это уступка для удобства, а не изменение порядка. В WHERE и HAVING алиасы не работают никогда.
WHERE фильтрует строки, HAVING фильтрует группы
Задача: клиенты, сделавшие 10 и больше заказов в 2026 году.
SELECT customer_id, count(*) AS orders
FROM orders
WHERE created_at >= '2026-01-01' -- какие строки вообще считаем
GROUP BY customer_id
HAVING count(*) >= 10; -- какие группы оставляемТаких клиентов 25. Если убрать WHERE, получится 140: это клиенты с десятью заказами за всё время, совсем другой вопрос.
Правило простое. Условие на исходные строки пишется в WHERE, условие на результат агрегата в HAVING. Условие на обычную колонку технически можно написать и в HAVING, если она входит в GROUP BY, но тогда база сначала сгруппирует всё подряд, а потом выбросит лишнее. WHERE честнее описывает намерение и даёт оптимизатору отфильтровать строки до группировки.
Фильтр меняет то, что видят агрегаты и окна
Это самое важное следствие порядка. Задача: найти клиентов, чей самый первый заказ сделан в 2026 году. Напрашивается такой запрос:
SELECT customer_id, min(created_at) AS first_order_at
FROM orders
WHERE created_at >= '2026-01-01'
GROUP BY customer_id;Он возвращает 1278 клиентов, и это неверный ответ. WHERE убрал заказы 2025 года до группировки, поэтому min нашёл первый заказ среди оставшихся. Получились клиенты, у которых в 2026 году был хоть один заказ.
Условие относится не к строкам, а к результату агрегата, значит его место в HAVING:
SELECT customer_id, min(created_at) AS first_order_at
FROM orders
GROUP BY customer_id
HAVING min(created_at) >= '2026-01-01';Таких клиентов 154. Разница в восемь раз, и обе цифры выглядят правдоподобно. Именно поэтому такие ошибки доезжают до отчётов.
С оконными функциями то же самое. row_number() OVER (PARTITION BY customer_id ORDER BY created_at) нумерует только те заказы, которые пережили WHERE. Если отфильтровать год в том же запросе, номер 1 получит первый заказ года, а не первый заказ клиента.
Оконную функцию нельзя фильтровать в том же запросе
SELECT customer_id, order_id,
row_number() OVER (PARTITION BY customer_id ORDER BY created_at) AS rn
FROM orders
WHERE rn = 2;ERROR: column "rn" does not existОкна считаются на шаге 5, после WHERE и HAVING. Отфильтровать их результат можно только уровнем выше:
SELECT customer_id, order_id, created_at
FROM (
SELECT customer_id, order_id, created_at,
row_number() OVER (PARTITION BY customer_id ORDER BY created_at) AS rn
FROM orders
) numbered
WHERE rn = 2;Вторых заказов в базе 1340: столько клиентов сделали хотя бы два заказа.
LIMIT без ORDER BY не гарантирует ничего
LIMIT выполняется последним и обрезает то, что пришло. Без ORDER BY порядок строк не определён: сегодня база читает таблицу подряд, завтра после обновления строк или параллельного чтения порядок другой. Запрос «дай 1000 строк для проверки» вернёт разные строки на деве и на проде.
Для инженера данных это не теория. Выгрузка страницами через LIMIT и OFFSET без сортировки по уникальному ключу пропускает одни строки и дублирует другие. Если сортируете по неуникальной колонке, добавляйте ключ: ORDER BY created_at DESC, order_id DESC.
Практика
Во всех трёх задачах заготовка написана так, как её часто пишут с первого раза. Она либо падает, либо возвращает правдоподобный, но неверный результат.
Найдите позиции заказов, где стоимость без учёта скидки больше 150 000. Колонки: order_id, line_no, amount, где amount равен quantity * unit_price.
Заготовка падает с ошибкой. Исправьте её так, чтобы формула стоимости была записана в запросе один раз.