Когда заворачивать логику в WITH, а когда обычный подзапрос честнее и короче. Один и тот же запрос показан в двух формах: подсвети логический шаг — и увидишь, где подзапросы заставляют дублировать код. Плюс правда про производительность и разбор рекурсивного CTE.
Если для понимания запроса приходится читать его изнутри наружу — переписывай на CTE. Если шаг один и он влезает в строку — оставляй подзапрос, обёртка ничего не улучшит.
Определение
Что такое CTE
CTE (Common Table Expression, обобщённое табличное выражение) — именованный подзапрос, объявленный через WITH перед основным запросом. Живёт только во время выполнения этого запроса: в базе ничего не создаётся и после ничего не остаётся.
Синтаксис
SQL
WITH имя_блока AS (
SELECT ...
)
SELECT * FROM имя_блока;
Блоков может быть несколько — через запятую после одного WITH. Каждый следующий видит предыдущие, и так расчёт раскладывается на последовательные шаги:
Несколько CTE подряд
SQL
WITH paid AS (
SELECT * FROM orders WHERE status = 'paid'
),
revenue AS (
SELECT user_id, SUM(sum) AS revenue
FROM paid -- видит предыдущий блокGROUP BY user_id
)
SELECT * FROM revenue ORDER BY revenue DESC;
// Ссылаться вперёд нельзя
Блок видит только те, что объявлены выше него. Обращение к CTE, объявленному ниже, — ошибка. Единственное исключение — WITH RECURSIVE, где блок может ссылаться сам на себя.
Попробовать
Один запрос в двух формах
Задача обычная для аналитика: найти пользователей, чья выручка выше средней по всем пользователям. Логика раскладывается на три шага — посчитать выручку по каждому, найти среднюю, отобрать тех, кто выше.
Переключай форму записи и подсвечивай шаги. Обрати внимание, что происходит с первым шагом в версии на подзапросах.
// Одна задача, две записи
пользователи с выручкой выше средней
Строк в запросе
—
Блок «выручка» написан
—
// Что здесь главное
Подсветив шаг 1 в версии с подзапросами, увидишь два одинаковых блока. Расчёт выручки пришлось написать дважды: один раз чтобы выбрать из него, второй — чтобы посчитать среднюю. Меняешь условие в одном месте, забываешь во втором — и отчёт врёт. В версии с CTE этот блок объявлен один раз.
// Результат обеих версий
Запросы возвращают одно и то же
user 1 → 2000 · user 2 → 1500 (средняя 1200)
Выручка по пользователям: 2000, 1500, 1000 и 300. Средняя — 1200. Выше неё двое. CTE не меняет результат, он меняет только то, как запрос читается и поддерживается.
За
Три причины выбрать CTE
Порядок чтения. Подзапросы читаются изнутри наружу, CTE — сверху вниз, как обычный текст. На третьем уровне вложенности это перестаёт быть вопросом вкуса.
Никакого дублирования. Блок объявлен один раз, а обращаться к нему можно сколько угодно. Правка делается в одном месте.
Отладка по кускам. Любой CTE — готовый самостоятельный запрос: скопировал тело, выполнил, проверил промежуточный результат.
Имена вместо t1 и t2.user_revenue объясняет смысл шага, а t2 — нет. Запрос становится документацией к самому себе.
Последний пункт недооценивают. Через месяц собственный запрос читаешь как чужой, и осмысленные имена блоков экономят больше времени, чем любые комментарии.
Против
Когда подзапрос уместнее
Обратная крайность встречается не реже: запрос из одного шага, обёрнутый в WITH ради красоты. Это добавляет три строки и ничего не объясняет.
Здесь CTE лишний
SQL
-- Избыточно: один шаг, обёрнутый в WITHWITH avg_sum AS (SELECTAVG(sum) AS a FROM orders)
SELECT * FROM orders, avg_sum WHERE sum > avg_sum.a;
-- Достаточно скалярного подзапросаSELECT * FROM orders
WHERE sum > (SELECTAVG(sum) FROM orders);
Скалярный подзапрос. Одно значение в условии — WHERE sum > (SELECT AVG(...)). Оборачивать незачем.
EXISTS и IN. Проверка «есть ли связанная запись» читается на месте и в CTE только теряет смысл.
Один шаг без переиспользования. Если блок нужен ровно раз и умещается в пару строк, подзапрос честнее.
Цепочка из десяти CTE. Обратная крайность: запрос-простыня, где каждый блок используется один раз. Часть шагов стоит слить.
Разбор мифа
Правда ли CTE медленнее
Утверждение «CTE тормозит» до сих пор кочует по статьям и собеседованиям. Оно было верным — и перестало.
В PostgreSQL до версии 12 CTE был оптимизационным барьером: он всегда материализовался, то есть вычислялся целиком в отдельный промежуточный набор. Планировщик не мог протолкнуть внутрь условие из внешнего запроса, и CTE читал больше данных, чем нужно. Отсюда и миф.
В PostgreSQL 12 и новее поведение другое: CTE подставляется в запрос — инлайнится, — если выполняются три условия сразу.
Используется ровно один раз
Не рекурсивный
Без побочных эффектов (нет INSERT / UPDATE / DELETE внутри)
Иначе — материализуется, как раньше
Поведение можно задать и явно:
Управление материализацией
PostgreSQL 12+
WITH x ASMATERIALIZED (...) -- посчитать один раз и переиспользоватьWITH y ASNOT MATERIALIZED (...) -- подставить в запрос как подзапрос
// Разворот аргумента
В нашем примере user_revenue используется дважды — значит, PostgreSQL его материализует и посчитает один раз. А версия с подзапросами содержит два одинаковых блока, и база честно выполнит расчёт дважды. Здесь CTE не медленнее, а быстрее.
// Не верь на слово, смотри план
Диалекты ведут себя по-разному, и общих правил тут мало. Единственный надёжный способ — EXPLAIN ANALYZE на своих данных. Спор «CTE или подзапрос быстрее» без плана запроса — спор ни о чём.
Бонус
Рекурсивный CTE: то, что подзапросом не сделать
Единственная возможность, которой у подзапросов нет вовсе. WITH RECURSIVE состоит из двух частей, соединённых через UNION ALL: стартовой строки и правила, как получить следующую из предыдущей.
Календарь без пропусков
SQL
WITH RECURSIVE dates AS (
SELECTDATE'2026-01-01'AS d -- стартUNION ALLSELECT d + 1FROM dates
WHERE d < DATE'2026-01-07'-- условие остановки
)
SELECT d FROM dates;
-- 7 строк: 01.01 … 07.01
Зачем это аналитику: присоединив такой календарь к продажам через LEFT JOIN, получаешь дни с нулём вместо пропущенных строк. График перестаёт врать, а среднее — считаться только по дням, когда что-то происходило.
Второе типовое применение — пройти по иерархии: дерево категорий, цепочка руководителей, связанные комментарии.
Обход иерархии
SQL
WITH RECURSIVE chain AS (
SELECT id, name, 1AS lvl
FROM emp WHERE boss IS NULL-- кореньUNION ALLSELECT e.id, e.name, c.lvl + 1FROM emp e JOIN chain c ON e.boss = c.id -- шаг вглубь
)
SELECT lvl, name FROM chain ORDER BY lvl;
// Условие остановки обязательно
Без него запрос уйдёт в бесконечный цикл. И осторожнее с иерархиями, где данные могут содержать петлю: сотрудник, оказавшийся собственным начальником через цепочку, повесит запрос. На всякий случай ограничивают глубину — добавляют в рекурсивную часть WHERE lvl < 100.
Практика
Проверь себя
1
В версии с подзапросами из интерактива блок расчёта выручки написан дважды. Что сломается, если поменять status = 'paid' только в одном из них?
Показать ответ
Запрос отработает без ошибки и вернёт неверный результат: пользователей отберут по одному набору заказов, а среднюю посчитают по другому. Самый неприятный класс багов — тихий. Ради защиты от него CTE и берут: блок объявлен один раз, править негде.
2
Выручка пользователей: 2000, 1500, 1000 и 300. Сколько из них попадут в результат «выше средней» и почему не половина?
Показать ответ
Средняя — (2000 + 1500 + 1000 + 300) / 4 = 1200. Выше неё двое: 2000 и 1500. Половина получилась случайно — среднее чувствительно к выбросам, и один крупный пользователь может утащить его так, что «выше среднего» окажется вообще один. Когда нужна устойчивая граница, берут медиану, а не среднее.
3
Можно ли в первом CTE сослаться на второй, объявленный ниже?
Показать ответ
Нет. Блок видит только объявленные выше него — при ссылке вперёд база сообщит, что такой таблицы не существует. Единственное исключение — WITH RECURSIVE, где блок может ссылаться сам на себя. Если понадобилась ссылка вперёд, порядок блоков нужно поменять.
4
В PostgreSQL 14 есть CTE, к которому обращаются один раз. Материализуется он или подставится в запрос?
Показать ответ
Подставится (инлайнится): начиная с версии 12 это поведение по умолчанию для CTE, который используется ровно раз, не рекурсивен и без побочных эффектов. Планировщик сможет протолкнуть внутрь условия из внешнего запроса. Если материализация нужна принудительно — AS MATERIALIZED.
FAQ
Частые вопросы про CTE
Что такое CTE в SQL?
CTE (Common Table Expression, обобщённое табличное выражение) — именованный подзапрос, объявленный через WITH перед основным запросом. Существует только во время выполнения этого запроса: в базе ничего не создаётся. Синтаксис: WITH имя AS (SELECT …) SELECT … FROM имя. К одному CTE можно обращаться несколько раз.
Что лучше — CTE или подзапрос?
Зависит от сложности. Для одного простого шага подзапрос короче — заворачивать его в WITH незачем. CTE выигрывает, когда шагов несколько, когда промежуточный результат нужен дважды или когда вложенность доходит до двух уровней и глубже. Правило: если запрос приходится читать изнутри наружу — пора на CTE.
CTE работает медленнее подзапроса?
Устаревшее убеждение. В PostgreSQL до 12-й версии CTE был оптимизационным барьером и всегда материализовался — отсюда миф. С версии 12 он инлайнится, если используется ровно раз, не рекурсивен и без побочных эффектов; поведение задаётся явно через AS MATERIALIZED / AS NOT MATERIALIZED. При повторном обращении CTE считается один раз, а версия с подзапросами выполнит тот же блок дважды.
Можно ли использовать несколько CTE в одном запросе?
Да, они перечисляются через запятую после одного WITH. Каждый следующий может ссылаться на предыдущие — удобный способ разложить сложный расчёт на шаги. Ссылаться вперёд, на объявленный ниже блок, нельзя (кроме рекурсивного CTE).
Чем CTE отличается от временной таблицы и от VIEW?
CTE живёт внутри одного запроса и ничего не создаёт в базе. Временная таблица создаётся физически и живёт до конца сессии — её можно переиспользовать в нескольких запросах и индексировать. VIEW хранится в базе постоянно и доступен всем. Правило: разовый расчёт — CTE, переиспользование в сессии — временная таблица, общая для команды логика — VIEW.
Что такое рекурсивный CTE и зачем он нужен?
Объявляется как WITH RECURSIVE и умеет ссылаться сам на себя: стартовая часть плюс рекурсивная, соединённые через UNION ALL. Два типовых применения: сгенерировать непрерывный ряд дат, чтобы в отчёте появились дни с нулём, и пройти по иерархии — дерево категорий или цепочка руководителей. Подзапросом это не выражается.
CTE — не «продвинутая версия» подзапроса, а другой способ записи той же логики. Результат одинаковый, отличается читаемость и поддержка.
Бери CTE, когда шагов несколько, когда промежуточный результат нужен дважды или когда запрос приходится читать изнутри наружу. Оставляй подзапрос, когда шаг один и умещается в строку. А спор о производительности без EXPLAIN ANALYZE на своих данных смысла не имеет: с PostgreSQL 12 старый аргумент про барьер оптимизации не работает.
Практика: найди свой самый длинный запрос с вложенностью и посмотри, не написан ли в нём один и тот же блок дважды. Если да — у тебя уже есть кандидат на первый CTE.
АТ
Андрей Тарасенко
// Продуктовый аналитик · Авито · Ментор
Дублированный блок внутри запроса — то, что я ищу первым делом при разборе чужого SQL. Он почти всегда означает, что рано или поздно правку внесут в одно место из двух, и отчёт начнёт тихо врать. CTE не делает запрос умнее, он просто убирает место, где можно ошибиться.
SQL-тренажёр на датасете маркетплейса: запрос выполняется по-настоящему и проверяется автоматически. Многошаговые задачи, где без CTE становится больно, — с середины списка. 20 задач бесплатно.