Статья SQL // 10 мин чтения

CTE против подзапросов

Когда заворачивать логику в WITH, а когда обычный подзапрос честнее и короче. Один и тот же запрос показан в двух формах: подсвети логический шаг — и увидишь, где подзапросы заставляют дублировать код. Плюс правда про производительность и разбор рекурсивного CTE.

Короткий ответ

ПодзапросCTE (WITH)
Один простой шагКороче и понятнееЛишняя обёртка
Несколько шагов подрядРастёт вложенностьЧитается сверху вниз
Результат нужен дваждыКод дублируетсяОбъявлен один раз
Порядок чтенияИзнутри наружуСверху вниз
Отладка по кускамНадо выковыриватьБлок готов к запуску
РекурсияНевозможнаWITH RECURSIVE
// Правило одной строки
Если для понимания запроса приходится читать его изнутри наружу — переписывай на 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
-- Избыточно: один шаг, обёрнутый в WITH
WITH avg_sum AS (SELECT AVG(sum) AS a FROM orders)
SELECT * FROM orders, avg_sum WHERE sum > avg_sum.a;

-- Достаточно скалярного подзапроса
SELECT * FROM orders
WHERE sum > (SELECT AVG(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 AS MATERIALIZED (...)      -- посчитать один раз и переиспользовать
WITH y AS NOT MATERIALIZED (...)  -- подставить в запрос как подзапрос
// Разворот аргумента
В нашем примере user_revenue используется дважды — значит, PostgreSQL его материализует и посчитает один раз. А версия с подзапросами содержит два одинаковых блока, и база честно выполнит расчёт дважды. Здесь CTE не медленнее, а быстрее.
// Не верь на слово, смотри план
Диалекты ведут себя по-разному, и общих правил тут мало. Единственный надёжный способ — EXPLAIN ANALYZE на своих данных. Спор «CTE или подзапрос быстрее» без плана запроса — спор ни о чём.

Рекурсивный CTE: то, что подзапросом не сделать

Единственная возможность, которой у подзапросов нет вовсе. WITH RECURSIVE состоит из двух частей, соединённых через UNION ALL: стартовой строки и правила, как получить следующую из предыдущей.

Календарь без пропусков
SQL
WITH RECURSIVE dates AS (
    SELECT DATE '2026-01-01' AS d          -- старт
    UNION ALL
    SELECT d + 1 FROM 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, 1 AS lvl
    FROM emp WHERE boss IS NULL            -- корень
    UNION ALL
    SELECT e.id, e.name, c.lvl + 1
    FROM 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.

Частые вопросы про 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 — не «продвинутая версия» подзапроса, а другой способ записи той же логики. Результат одинаковый, отличается читаемость и поддержка.

Бери CTE, когда шагов несколько, когда промежуточный результат нужен дважды или когда запрос приходится читать изнутри наружу. Оставляй подзапрос, когда шаг один и умещается в строку. А спор о производительности без EXPLAIN ANALYZE на своих данных смысла не имеет: с PostgreSQL 12 старый аргумент про барьер оптимизации не работает.

Практика: найди свой самый длинный запрос с вложенностью и посмотри, не написан ли в нём один и тот же блок дважды. Если да — у тебя уже есть кандидат на первый CTE.

АТ
Андрей Тарасенко
// Продуктовый аналитик · Авито · Ментор

Дублированный блок внутри запроса — то, что я ищу первым делом при разборе чужого SQL. Он почти всегда означает, что рано или поздно правку внесут в одно место из двух, и отчёт начнёт тихо врать. CTE не делает запрос умнее, он просто убирает место, где можно ошибиться.

Написать в Telegram
// ЗАКРЕПИ НА ПРАКТИКЕ

Собери свой первый CTE на живой базе

SQL-тренажёр на датасете маркетплейса: запрос выполняется по-настоящему и проверяется автоматически. Многошаговые задачи, где без CTE становится больно, — с середины списка. 20 задач бесплатно.

▶ Открыть SQL-тренажёр ★ GROUP BY и HAVING
Все материалы: База знаний · Telegram