Порядок выполнения SQL-запроса: от FROM до LIMIT
Логический порядок выполнения SQL-запроса по шагам: FROM, WHERE, GROUP BY, HAVING, оконные функции, SELECT, DISTINCT, ORDER BY, LIMIT. Почему алиас не виден в WHERE.
Содержание статьи
Вы считаете годовую стоимость подписки: amount * 12 as yearly. Строкой ниже хотите оставить платежи дороже 400 и пишете where yearly > 400. PostgreSQL отвечает, что столбца yearly не существует, хотя вы назвали его секунду назад. При этом order by yearly в том же запросе работает. Объяснение одно: база выполняет части запроса не в том порядке, в каком они записаны. SELECT стоит первым в тексте, но выполняется почти в конце, когда WHERE уже отработал. Из того же порядка следует, почему агрегат нельзя сравнить в WHERE, почему оконная функция не фильтруется без подзапроса и почему LIMIT не помогает посчитать «среднее по последним десяти». Ниже порядок выполнения SQL-запроса разобран по шагам на учебной базе Симулятора SQL-аналитика: 4613 пользователей и 1251 платёж.
Коротко
Логический порядок выполнения SELECT-запроса: FROM и JOIN → WHERE → GROUP BY → HAVING → оконные функции → SELECT → DISTINCT → ORDER BY → LIMIT. Каждый шаг получает на вход то, что оставил предыдущий, и не знает о том, что появится позже.
- WHERE работает до SELECT, поэтому алиас из SELECT в нём не виден. В ORDER BY алиас виден: сортировка идёт после SELECT.
- WHERE фильтрует строки до группировки, HAVING — готовые группы. Условие с
count(*)илиsum(...)возможно только в HAVING. - Оконные функции считаются после HAVING по тем строкам, что остались. Отфильтровать по их результату можно только во внешнем запросе.
- DISTINCT убирает дубли среди готовых строк результата, а не в исходной таблице.
- LIMIT срабатывает последним. Чтобы посчитать что-то по первым N строкам, их сначала отбирают в подзапросе.
- Это логический порядок: он определяет результат и видимость имён. В каком порядке база читает таблицы на самом деле, решает оптимизатор.
Порядок записи и порядок выполнения
Слова в запросе идут в фиксированном порядке: SELECT, FROM, WHERE, GROUP BY, HAVING, ORDER BY, LIMIT. Поменять их местами нельзя, база вернёт синтаксическую ошибку. Но читает она запрос иначе: сначала выясняет, откуда брать строки, потом — какие из них оставить, и только после этого — что показать.
Запрос ниже отвечает на вопрос «какие два канала привлечения принесли больше всего денег в августе, если не считать каналы с десятком-другим платежей». В нём есть все основные части, и по таблице видно, что происходит со строками на каждом шаге.
На первом шаге платежи соединяются с пользователями: 1251 строка, по одной на платёж. WHERE оставляет 642 августовских. GROUP BY складывает их в четыре группы по каналу, и для каждой считаются count(*) и sum(amount). HAVING убирает paid_search с 71 платежом, остаются три канала. SELECT формирует три столбца и даёт им имена. ORDER BY сортирует по выручке, LIMIT отдаёт две верхние строки.
Обратите внимание на последние две строки запроса. В HAVING агрегат написан целиком, count(*) >= 80, а в ORDER BY стоит алиас revenue. Это не дело вкуса: на шаге HAVING имени payment_cnt ещё нет, на шаге ORDER BY имя revenue уже есть.
| Шаг | Часть запроса | Что делает | Что уже доступно | В примере |
|---|---|---|---|---|
| 1 | FROM, JOIN | собирает строки из таблиц | столбцы таблиц | 1251 строка |
| 2 | WHERE | отбрасывает отдельные строки | столбцы таблиц | 642 строки |
| 3 | GROUP BY | складывает строки в группы, считает агрегаты | столбцы таблиц | 4 группы |
| 4 | HAVING | отбрасывает целые группы | ключи групп и агрегаты | 3 группы |
| 5 | оконные функции | считают по оставшимся строкам | ключи групп и агрегаты | нет |
| 6 | SELECT | вычисляет выражения и раздаёт алиасы | всё перечисленное | 3 столбца |
| 7 | DISTINCT | убирает одинаковые строки результата | строки результата | нет |
| 8 | ORDER BY | сортирует результат | столбцы результата и алиасы | 3 строки по убыванию выручки |
| 9 | LIMIT, OFFSET | обрезает результат | готовый отсортированный результат | 2 строки |
select u.channel,
count(*) as payment_cnt,
sum(p.amount) as revenue
from payments as p
join users as u on u.user_id = p.user_id
where p.paid_at >= date '2026-08-01'
group by u.channel
having count(*) >= 80
order by revenue desc
limit 2;
-- organic | 283 | 7087.00
-- referral | 198 | 4792.00FROM и JOIN: сначала база собирает строки
Запрос начинается с FROM. База берёт таблицу, присоединяет к ней остальные по условиям из ON и получает один широкий набор строк. Все следующие шаги работают уже с ним, а не с исходными таблицами.
Отсюда два практических следствия. Первое: короткие имена таблиц, заданные в FROM (payments as p), известны всем остальным частям запроса, включая SELECT, который записан выше. Поэтому u.channel в первой строке запроса не вызывает ошибки, хотя u объявлено тремя строками ниже.
Второе: число строк определяется здесь, до любых фильтров и агрегатов. Если у пользователя три платежа, после соединения у него три строки, и count(*) посчитает платежи, а не людей. Когда итоговое число кажется завышенным, проверять нужно первый шаг: сколько строк вышло из FROM и JOIN. Про это есть отдельный разбор — JOIN без потерь и дублей.
Условие в ON относится к этому же шагу, а WHERE — к следующему. Для обычного JOIN разницы в результате нет. Для LEFT JOIN она есть: условие в ON решает, какие строки присоединять, а условие в WHERE выбрасывает строки уже после соединения, в том числе те, что остались без пары. Пример с числами — в статье про LEFT JOIN.
Почему алиас не работает в WHERE
WHERE — второй шаг. К этому моменту у базы есть только столбцы таблиц из FROM. Выражения из SELECT ещё не вычислены, имён у них нет. Поэтому запрос из начала статьи PostgreSQL отклоняет, даже не начав выполнять.
Тот же запрос в DuckDB выполняется и возвращает три платежа по тарифу team. DuckDB сам подставляет вместо алиаса выражение, которое за ним стоит. На DuckDB работает песочница симулятора и проверка в курсах, так что запрос с алиасом в WHERE там пройдёт, а на рабочей базе с PostgreSQL упадёт. Привыкать к этой вольности не стоит.
Переносимых способов два. Короткое выражение проще повторить: where amount * 12 > 400. Таких платежей 142, и запрос работает в любой базе. Длинное выражение удобнее вычислить в подзапросе или CTE: во внешнем запросе алиас становится обычным столбцом, и WHERE его видит. Как устроен такой запрос, показано в статье про CTE, а остальные правила фильтра — в статье про WHERE.
По той же причине в WHERE нельзя написать count(*) > 100. Агрегаты появляются на шаге GROUP BY, а он ещё не наступил.
select payment_id, plan, amount * 12 as yearly
from payments
where yearly > 400
order by payment_id
limit 3;
-- 4 | team | 468.00
-- 24 | team | 468.00
-- 35 | team | 468.00ERROR: column "yearly" does not exist
LINE 3: where yearly > 400
^WHERE или HAVING: фильтр до группировки и после
WHERE и HAVING делают похожую работу на разных шагах. WHERE решает судьбу каждой строки до того, как строки сложатся в группы. HAVING смотрит на готовую группу и решает, оставить её целиком или убрать. Отсюда простое правило выбора: условие про строку — в WHERE, условие про группу — в HAVING.
Важнее другое: WHERE меняет сами числа, а HAVING — только список групп. Без фильтров платежи распределены по каналам так: organic 566, referral 384, partner 198, paid_search 103. Добавим where p.plan = 'team' — и счётчики становятся другими: 66, 45, 22 и 9, потому что в группы попали только платежи по тарифу team. Теперь добавим having count(*) >= 30. Числа не изменились, но из четырёх строк остались две.
Условие по ключу группировки формально можно написать в обоих местах. having u.channel <> 'organic' и where u.channel <> 'organic' возвращают одни и те же три строки: 384, 198 и 103. Пишут такое условие в WHERE: так запрос читается честнее, и база не тратит время на группу, которую потом выбросит.
Обратное не работает. Условие на агрегат в WHERE — ошибка в любой базе. Подробный разбор с примерами — в статье HAVING в SQL, а сама группировка — в статье про GROUP BY и COUNT.
select u.channel,
count(*) as payment_cnt,
sum(p.amount) as revenue
from payments as p
join users as u on u.user_id = p.user_id
where p.plan = 'team'
group by u.channel
having count(*) >= 30
order by payment_cnt desc;
-- organic | 66 | 2574.00
-- referral | 45 | 1755.00GROUP BY и алиас: работает, но с оговоркой
GROUP BY выполняется раньше SELECT, так что по логике алиас в нём тоже не должен быть виден. Стандарт SQL так и считает. Но PostgreSQL и DuckDB делают исключение: в GROUP BY можно написать имя столбца результата или его номер (group by 1). Первый запрос ниже считает регистрации по месяцам и работает в обоих движках: 1042 в июне, 1676 в июле, 1895 в августе.
Оговорка касается имён. Если алиас совпал с именем столбца таблицы, GROUP BY выберет столбец таблицы: исходные данные для него важнее. Во втором запросе выражение с началом месяца названо signup_date — так же, как столбец в users. Группировка идёт по исходной дате, то есть по дням. В результате 90 строк вместо трёх, и в каждой стоит первое число месяца. Ошибки нет, запрос выполнился, просто отчёт по месяцам превратился в отчёт по дням с неверными подписями.
ORDER BY в том же запросе ведёт себя наоборот: он выполняется после SELECT и одноимённый алиас предпочитает столбцу таблицы. Поэтому строки отсортированы по первому числу месяца, а внутри него — по счётчику.
Защита простая: давайте вычисленному столбцу имя, которого нет в таблицах. signup_month вместо signup_date, payment_cnt вместо payments. А в HAVING алиас в PostgreSQL не работает вовсе — там агрегат пишут целиком.
select cast(date_trunc('month', signup_date) as date) as signup_month,
count(*) as user_cnt
from users
group by signup_month
order by signup_month;
-- 2026-06-01 | 1042
-- 2026-07-01 | 1676
-- 2026-08-01 | 1895select cast(date_trunc('month', signup_date) as date) as signup_date,
count(*) as user_cnt
from users
group by signup_date
order by signup_date, user_cnt
limit 4;
-- 2026-06-01 | 14
-- 2026-06-01 | 15
-- 2026-06-01 | 16
-- 2026-06-01 | 17Когда выполняются оконные функции
Оконные функции — row_number(), sum(...) over (...), lag() — считаются после WHERE, GROUP BY и HAVING, но до DISTINCT, ORDER BY и LIMIT. Окно видит только те строки, которые дошли до этого шага. Если в запросе есть группировка, строками для окна становятся группы.
На этом построен удобный приём: sum(count(*)) over (). Внутренний count(*) — обычный агрегат, он посчитан на шаге GROUP BY. Внешний sum(...) over () складывает эти счётчики по всем группам. Делим одно на другое и получаем долю канала в общем числе платежей: 45,2% у organic, 30,7% у referral, 15,8% у partner и 8,2% у paid_search.
Теперь добавим в этот запрос having count(*) >= 150, чтобы скрыть мелкий канал. Строка paid_search исчезла, как и задумано, но доли остальных выросли: 49,3%, 33,4% и 17,2%. HAVING отработал раньше окна, и окно сложило только три оставшихся счётчика. Сумма долей снова 100%, и по таблице не видно, что из знаменателя пропали 103 платежа.
Если доля должна считаться от всех платежей, порядок шагов нужно задать самому: сначала посчитать доли в CTE, потом отфильтровать готовые строки во внешнем запросе. Больше примеров — в статьях про долю и процент в SQL и про оконные функции.
select u.channel,
count(*) as payment_cnt,
round(count(*) * 100.0 / sum(count(*)) over (), 1) as share_pct
from payments as p
join users as u on u.user_id = p.user_id
group by u.channel
having count(*) >= 150
order by payment_cnt desc;
-- organic | 566 | 49.3
-- referral | 384 | 33.4
-- partner | 198 | 17.2with shares as (
select u.channel,
count(*) as payment_cnt,
round(count(*) * 100.0 / sum(count(*)) over (), 1) as share_pct
from payments as p
join users as u on u.user_id = p.user_id
group by u.channel
)
select channel, payment_cnt, share_pct
from shares
where payment_cnt >= 150
order by payment_cnt desc;
-- organic | 566 | 45.2
-- referral | 384 | 30.7
-- partner | 198 | 15.8Почему оконную функцию нельзя поставить в WHERE
Типичная задача: найти первый платёж каждого пользователя. Нумеруем платежи через row_number() и хотим оставить строки с номером 1. Написать where row_number() over (...) = 1 нельзя: PostgreSQL отвечает «window functions are not allowed in WHERE», DuckDB — «WHERE clause cannot contain window functions». Причина та же, что с алиасом, только шагов между ними больше: WHERE второй, окна — пятые.
В HAVING оконную функцию тоже не поставить: он идёт перед окнами. Остаётся разнести запрос на два уровня. Внутренний нумерует строки, внешний фильтрует по номеру, который для него уже обычный столбец. Запрос ниже так и устроен: он берёт первый платёж каждого из 912 плательщиков и считает, с какого тарифа люди начинают. Чаще всего это basic — 514 первых платежей, у pro 296, у team 102.
В DuckDB, Snowflake и BigQuery для этой задачи есть слово QUALIFY — фильтр, который выполняется после оконных функций, как HAVING после агрегатов. В PostgreSQL его нет, поэтому в курсах и в этой статье используется вариант с CTE: он работает везде.
with numbered as (
select user_id, plan,
row_number() over (partition by user_id order by paid_at, payment_id) as rn
from payments
)
select plan, count(*) as first_payments
from numbered
where rn = 1
group by plan
order by first_payments desc;
-- basic | 514
-- pro | 296
-- team | 102SELECT и DISTINCT: дубли убираются среди готовых строк
SELECT — шестой шаг. Здесь вычисляются выражения и появляются алиасы. Всё, что идёт дальше, работает уже со строками результата.
DISTINCT выполняется сразу после SELECT и сравнивает строки результата целиком, по всем столбцам. Он не знает, какой из столбцов вы считаете «главным». select distinct channel from users возвращает четыре строки: каналов четыре. Стоит добавить в список столбец, который у каждой строки свой, и DISTINCT перестаёт что-либо убирать.
Во втором подзапросе такой столбец — номер строки. Оконная функция считается раньше DISTINCT, поэтому к моменту удаления дублей у каждой из 4613 строк уже есть собственный номер, и одинаковых строк нет. Тот же эффект дают идентификатор или время события в списке SELECT.
Обратный случай: DISTINCT применяют, чтобы спрятать дубли после неудачного JOIN. Он уберёт повторы в выводе, но sum и count к этому моменту уже посчитаны по размноженным строкам. Чинить нужно первый шаг, а не седьмой. Подробнее — в статье про DISTINCT.
select 1 as n, 'distinct channel' as variant, count(*) as row_cnt
from (select distinct channel from users) as t
union all
select 2, 'distinct channel, row_number()', count(*)
from (
select distinct channel, row_number() over (order by user_id) as rn
from users
) as t
order by n;
-- 4 и 4613ORDER BY и LIMIT: последние шаги
ORDER BY выполняется после SELECT. Это единственная часть запроса, где алиас работает в любой базе: order by revenue desc в первом примере. До ORDER BY порядок строк не определён. Таблица в базе — не список, и без явной сортировки строки могут прийти в любом порядке, причём при следующем запуске — в другом.
LIMIT срабатывает самым последним и обрезает готовый результат. Из этого следуют две ошибки. Первая: LIMIT без ORDER BY возвращает произвольные строки, а не «первые» или «последние». Вторая интереснее: LIMIT нельзя использовать, чтобы ограничить данные для агрегата.
Допустим, нужно среднее по десяти последним платежам. Запрос select avg(amount) from payments limit 10 выглядит подходящим, но вернёт 24,49 — среднее по всем 1251 платежам. К моменту LIMIT агрегат уже посчитан, в результате одна строка, и LIMIT 10 оставляет её как есть. Правильный запрос сначала отбирает десять строк в подзапросе, с сортировкой и LIMIT, а потом считает по ним среднее: 22,00.
В сортировке подзапроса два столбца: paid_at desc, payment_id desc. Дата платежа не уникальна, и без второго столбца десятая строка выбиралась бы случайно среди платежей одного дня. Про связку сортировки и обрезки — статья ORDER BY и LIMIT.
select 1 as n, 'все платежи' as variant, round(avg(amount), 2) as avg_amount
from payments
union all
select 2, 'десять последних', round(avg(amount), 2)
from (
select amount
from payments
order by paid_at desc, payment_id desc
limit 10
) as last_payments
order by n;
-- 24.49 и 22.00Логический порядок — это не план выполнения
Всё описанное выше — логический порядок. Он отвечает на два вопроса: какой результат вернёт запрос и какие имена в какой его части доступны. На вопрос «что база делает физически» он не отвечает.
Реальный порядок действий выбирает оптимизатор. Он вправе переставлять шаги как угодно, если результат от этого не изменится. Самая частая перестановка — фильтр раньше соединения. В запросе ниже условие u.country = 'KZ' записано в WHERE, то есть логически применяется после JOIN. В плане PostgreSQL видно другое: фильтр по стране стоит внутри чтения таблицы users, и в соединение попадают только пользователи из Казахстана. Результат тот же, работы меньше.
План читается снизу вверх и изнутри наружу: сначала чтение таблиц, потом соединение, группировка и сортировка. Отсюда практический вывод. Не нужно переписывать запрос, перенося условия из WHERE в подзапросы «чтобы отфильтровать пораньше»: для обычного JOIN база сделает это сама. А когда запрос работает медленно, смотреть нужно не на порядок слов, а на план. Как его получить и прочитать — в статье про EXPLAIN ANALYZE.
select u.channel, count(*) as payment_cnt
from payments as p
join users as u on u.user_id = p.user_id
where u.country = 'KZ'
group by u.channel
order by payment_cnt desc;
-- organic 137, referral 78, partner 38, paid_search 20Sort
Sort Key: (count(*)) DESC
-> HashAggregate
Group Key: u.channel
-> Hash Join
Hash Cond: (p.user_id = u.user_id)
-> Seq Scan on payments p
-> Hash
-> Seq Scan on users u
Filter: (country = 'KZ'::text)Чем отличаются PostgreSQL и DuckDB
Сам порядок шагов в базах одинаковый. Различается строгость: насколько база готова подставить выражение вместо алиаса там, где по порядку его ещё нет. В таблице — то, что проверено запуском на PostgreSQL 14 и на DuckDB из песочницы симулятора.
PostgreSQL ближе к стандарту: алиас работает в ORDER BY и, как исключение, в GROUP BY. DuckDB разрешает его почти везде, включая соседний столбец того же SELECT: amount * 12 as yearly, yearly / 2 as half_year. MySQL, судя по документации, занимает место посередине: алиас допустим в GROUP BY, HAVING и ORDER BY, но не в WHERE.
Если запрос должен работать в нескольких базах, ориентируйтесь на строгий вариант. В WHERE и HAVING — выражение целиком, в ORDER BY — алиас без арифметики, для фильтра по вычисленному столбцу или оконной функции — CTE. Проверка заданий в курсах «SQL с нуля» и «Продвинутый SQL» подсказывает, когда принятый запрос опирается на вольность DuckDB.
| Место в запросе | PostgreSQL 14 | DuckDB |
|---|---|---|
| WHERE | ошибка: столбца нет | работает |
| GROUP BY | работает; одноимённый столбец таблицы важнее | работает; одноимённый столбец таблицы важнее |
| HAVING | ошибка: столбца нет | работает |
| ORDER BY, имя алиаса | работает | работает |
ORDER BY, выражение с алиасом (-user_cnt) | ошибка: столбца нет | работает |
| соседний столбец в SELECT | ошибка: столбца нет | работает |
| оконная функция в WHERE | ошибка | ошибка |
| QUALIFY | нет такого слова | есть |
Как пользоваться порядком выполнения при отладке
Порядок шагов полезен не только для того, чтобы понимать сообщения об ошибках. По нему удобно искать, на каком шаге число стало неверным: запрос собирают в том же порядке, в каком его выполняет база, и после каждого шага смотрят на число строк.
Запросы из статьи выполняются как есть в песочнице Симулятора SQL-аналитика. На странице «SQL онлайн» открыт редактор с другой учебной базой, авиаперевозками, и готовые примеры на те же шаги: WHERE, GROUP BY, HAVING, оконная функция. Задачи на эти темы с проверкой ответа собраны в каталоге задач по SQL.
- Начните с FROM и JOIN:
select count(*)без фильтров. Если строк больше, чем в главной таблице, соединение их размножило. - Добавьте WHERE и сравните число строк до и после. Разницу нужно уметь объяснить.
- Добавьте GROUP BY и проверьте, что сумма по группам равна числу строк с предыдущего шага.
- HAVING, окна и DISTINCT добавляйте по одному. Если в запросе есть и HAVING, и оконная функция, проверьте, от чего считается знаменатель.
- ORDER BY и LIMIT — в конце. Сортировка должна быть однозначной, иначе LIMIT будет возвращать разные строки от запуска к запуску.
Частые вопросы про порядок выполнения SQL-запроса
В каком порядке выполняются операторы SQL-запроса?
FROM и JOIN, затем WHERE, GROUP BY, HAVING, оконные функции, SELECT, DISTINCT, ORDER BY и в конце LIMIT с OFFSET. Это логический порядок: по нему определяется результат, а физический план база строит сама.
Почему алиас не работает в WHERE, но работает в ORDER BY?
WHERE выполняется до SELECT, и имени столбца для него ещё нет. ORDER BY выполняется после SELECT и алиас видит. В WHERE выражение повторяют целиком или выносят расчёт в CTE.
Что выполняется раньше — WHERE или HAVING?
WHERE. Он фильтрует строки до группировки, поэтому влияет на значения агрегатов. HAVING фильтрует готовые группы и значения не меняет.
Можно ли использовать алиас в GROUP BY?
В PostgreSQL и DuckDB — да, как и номер столбца. Если алиас совпадает с именем столбца таблицы, группировка пойдёт по столбцу таблицы, поэтому вычисленным столбцам дают имена, которых в таблицах нет.
Когда выполняются оконные функции?
После WHERE, GROUP BY и HAVING, перед DISTINCT и ORDER BY. Поэтому результат оконной функции нельзя проверить в WHERE и HAVING того же запроса: фильтр пишут во внешнем запросе.
Выполняется ли JOIN раньше WHERE?
Логически да: сначала соединение, потом фильтр. Физически оптимизатор часто фильтрует таблицу до соединения, если результат от этого не меняется.
Что выполняется раньше — SELECT или ORDER BY?
SELECT. Сортировка работает с готовыми столбцами результата, поэтому в ней можно использовать алиас. LIMIT применяется уже к отсортированным строкам.
Что попробовать на учебной базе
Три упражнения на те же шаги. Первое: возьмите запрос с долями каналов и замените HAVING на where u.channel <> 'paid_search'. Прежде чем запускать, решите, изменятся ли доли и почему. Второе: посчитайте число пользователей по странам и оставьте страны, где их больше 500, — сначала через HAVING, потом через CTE с алиасом в WHERE; оба запроса должны вернуть три строки. Третье: найдите последний платёж каждого пользователя и посчитайте, сколько из них пришлось на тариф team.
Если хочется пройти эти темы по порядку и с проверкой каждого запроса, они разобраны в курсе «SQL с нуля»: первые 10 уроков бесплатно, фильтр по группам и порядок шагов — в уроке про HAVING.
Материалы по теме

PARTITION BY и OVER в SQL: окно, группы и отличие от GROUP BY
PARTITION BY и OVER в SQL: окно и отличие от GROUP BY, доля от итога, накопительный итог, рамки ROWS и RANGE, фильтр по оконной функции.
SQL-запросы: 40 примеров для аналитика с результатами
Сорок SQL-запросов на одной учебной базе: SELECT и WHERE, COUNT и GROUP BY, JOIN, даты, оконные функции, DAU, retention и A/B-тест. У каждого запроса показан результат.

HAVING в SQL: фильтр после GROUP BY и отличие от WHERE
HAVING в SQL: фильтр после GROUP BY, отличие от WHERE, порядок выполнения запроса, HAVING COUNT и алиасы в PostgreSQL и DuckDB.