CTE в SQL: как использовать WITH
Зачем аналитику CTE
CTE (Common Table Expression) — способ разбить сложный запрос на понятные шаги. Если ваши подзапросы вложены в три уровня — вам нужен WITH.
Реальные аналитические запросы редко бывают простыми. Нужно подготовить данные, отфильтровать, агрегировать, сджойнить с другой таблицей, снова агрегировать. Без CTE всё это превращается в нечитаемую кашу из вложенных подзапросов. С CTE — в последовательность именованных шагов, где каждый делает одну понятную операцию.
На собеседовании по SQL CTE встречается постоянно. Интервьюеры ожидают, что кандидат уровня middle и выше будет использовать WITH для декомпозиции сложных задач. Это не только про правильный ответ — это про читаемость и культуру написания SQL.
Базовый синтаксис
WITH имя_cte AS (
SELECT ...
FROM ...
WHERE ...
)
SELECT *
FROM имя_cteCTE объявляется ключевым словом WITH перед основным запросом. Внутри скобок — обычный SELECT. Результат получает имя, которое дальше используется как временная таблица.
Пример — средний чек по категориям товаров, только для категорий с выручкой больше миллиона:
WITH category_stats AS (
SELECT
category,
COUNT(*) AS order_count,
SUM(amount) AS revenue,
AVG(amount) AS avg_check
FROM orders
GROUP BY category
)
SELECT *
FROM category_stats
WHERE revenue > 1000000
ORDER BY avg_check DESCБез CTE это был бы подзапрос в FROM. С CTE — два чётких шага: сначала считаем статистику, потом фильтруем.
CTE vs подзапрос: когда что использовать
Подзапрос и CTE часто дают одинаковый результат. Разница — в читаемости и удобстве.
Подзапрос:
SELECT *
FROM (
SELECT
user_id,
COUNT(*) AS sessions,
ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC) AS rn
FROM user_sessions
WHERE session_date >= '2025-01-01'
GROUP BY user_id
) ranked
WHERE rn <= 10Тот же запрос через CTE:
WITH ranked AS (
SELECT
user_id,
COUNT(*) AS sessions,
ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC) AS rn
FROM user_sessions
WHERE session_date >= '2025-01-01'
GROUP BY user_id
)
SELECT *
FROM ranked
WHERE rn <= 10Для одного уровня вложенности разница невелика. Но как только появляется второй и третий уровень — подзапросы становятся нечитаемыми, а CTE сохраняет линейную структуру.
Когда подзапрос лучше:
- Простое условие в WHERE:
WHERE user_id IN (SELECT user_id FROM vip_users) - Одноразовое использование, 1–3 строки
Когда CTE лучше:
- Несколько шагов трансформации данных
- Один и тот же промежуточный результат используется дважды
- Нужна декомпозиция для отладки — каждый CTE можно запустить отдельно
Несколько CTE в одном запросе
Главная сила CTE — возможность объявить несколько именованных блоков и строить каждый следующий на основе предыдущего. Второе ключевое слово WITH не нужно — блоки перечисляются через запятую.
WITH daily_orders AS (
SELECT
DATE_TRUNC('day', order_date) AS dt,
COUNT(*) AS order_count,
SUM(amount) AS revenue
FROM orders
WHERE order_date >= '2025-01-01'
GROUP BY DATE_TRUNC('day', order_date)
),
weekly_avg AS (
SELECT
dt,
order_count,
revenue,
AVG(revenue) OVER (
ORDER BY dt
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS revenue_7d_avg
FROM daily_orders
)
SELECT
dt,
order_count,
revenue,
ROUND(revenue_7d_avg, 2) AS revenue_7d_avg
FROM weekly_avg
ORDER BY dtТри шага: агрегация по дням, расчёт скользящего среднего, итоговый вывод. Каждый CTE занимается одной задачей. Если что-то сломалось — легко понять, на каком шаге.
Пошаговая трансформация: воронка конверсии
На собеседованиях часто просят построить воронку: сколько пользователей дошло до каждого шага. CTE идеально подходит для таких задач — каждый шаг воронки становится отдельным блоком.
WITH registrations AS (
SELECT user_id, created_at
FROM users
WHERE created_at >= '2025-01-01'
AND created_at < '2025-02-01'
),
activated AS (
SELECT DISTINCT r.user_id
FROM registrations r
JOIN user_actions a ON a.user_id = r.user_id
WHERE a.action = 'complete_onboarding'
),
first_purchase AS (
SELECT DISTINCT r.user_id
FROM registrations r
JOIN orders o ON o.user_id = r.user_id
WHERE o.order_date <= r.created_at + INTERVAL '7 days'
),
funnel AS (
SELECT
(SELECT COUNT(*) FROM registrations) AS step_1_registered,
(SELECT COUNT(*) FROM activated) AS step_2_activated,
(SELECT COUNT(*) FROM first_purchase) AS step_3_purchased
)
SELECT
step_1_registered,
step_2_activated,
ROUND(100.0 * step_2_activated / step_1_registered, 1) AS activation_pct,
step_3_purchased,
ROUND(100.0 * step_3_purchased / step_1_registered, 1) AS purchase_pct
FROM funnelЧетыре CTE: когорта, активация, покупка, сборка воронки. Попробуйте написать это вложенными подзапросами — получится нечитаемо.
Рекурсивный CTE
Рекурсивный CTE — отдельная конструкция. Он позволяет запросу ссылаться на самого себя, что полезно для обхода иерархий: деревья сотрудников, категории товаров, цепочки рефералов.
Синтаксис:
WITH RECURSIVE имя_cte AS (
-- базовый случай (якорь)
SELECT ...
UNION ALL
-- рекурсивная часть — ссылается на имя_cte
SELECT ...
FROM имя_cte
JOIN ...
)
SELECT * FROM имя_cteКлассический пример — дерево сотрудников. Таблица employees содержит столбец manager_id, ссылающийся на id руководителя.
WITH RECURSIVE org_tree AS (
-- Якорь: генеральный директор (manager_id IS NULL)
SELECT
id,
name,
manager_id,
1 AS level
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Рекурсия: сотрудники, чей руководитель уже в дереве
SELECT
e.id,
e.name,
e.manager_id,
t.level + 1
FROM employees e
JOIN org_tree t ON e.manager_id = t.id
)
SELECT
level,
name,
manager_id
FROM org_tree
ORDER BY level, nameРезультат — все сотрудники с уровнем вложенности в иерархии. Генеральный директор — level 1, его прямые подчинённые — level 2, и так далее.
Рекурсивные CTE спрашивают на собеседованиях уровня middle+. Если вы идёте на позицию junior-аналитика, достаточно знать, что такая конструкция существует. Если на middle — нужно уметь написать.
CTE vs временная таблица
CTE и временная таблица (CREATE TEMP TABLE) решают похожую задачу — дать имя промежуточному результату. Но между ними есть принципиальные отличия.
| CTE | Временная таблица | |
|---|---|---|
| Область видимости | Один запрос | Вся сессия |
| Индексы | Нет | Можно создать |
| Повторное использование | Только внутри одного запроса | В нескольких запросах |
| Влияние на план запроса | Оптимизатор может «встроить» CTE в основной запрос | Всегда материализуется |
| Когда использовать | Декомпозиция одного запроса | Тяжёлые промежуточные результаты, используемые многократно |
В PostgreSQL 12+ оптимизатор может автоматически «развернуть» (inline) CTE, если он используется один раз — то есть CTE не создаёт накладных расходов. Если нужно принудительно материализовать CTE, используйте AS MATERIALIZED (...).
На собеседованиях этот вопрос формулируют так: «Когда вы выберете CTE, а когда временную таблицу?». Ответ: CTE — для структурирования одного запроса; временная таблица — когда промежуточный результат тяжёлый и нужен в нескольких последующих запросах.
Вопросы с собеседований
1. Что такое CTE и зачем он нужен?
CTE (Common Table Expression) — именованный временный результат, объявляемый через WITH. Нужен для декомпозиции сложных запросов: вместо вложенных подзапросов каждый шаг трансформации получает понятное имя. CTE улучшает читаемость, упрощает отладку и позволяет переиспользовать один и тот же промежуточный результат в нескольких местах основного запроса.
2. CTE материализуется или нет?
Зависит от СУБД. В PostgreSQL до версии 12 CTE всегда материализовался — оптимизатор не мог «заглянуть внутрь». С версии 12 CTE, используемый один раз и не являющийся рекурсивным, автоматически инлайнится. В BigQuery CTE всегда инлайнится. Чтобы принудительно материализовать — AS MATERIALIZED (...), чтобы инлайнить — AS NOT MATERIALIZED (...).
3. Найдите пользователей, у которых каждый заказ превышает их собственный средний чек.
WITH user_avg AS (
SELECT
user_id,
AVG(amount) AS avg_amount
FROM orders
GROUP BY user_id
),
below_avg AS (
SELECT o.user_id
FROM orders o
JOIN user_avg u ON o.user_id = u.user_id
WHERE o.amount <= u.avg_amount
)
SELECT DISTINCT user_id
FROM user_avg
WHERE user_id NOT IN (SELECT user_id FROM below_avg)Два CTE: средний чек по пользователю, затем пользователи, у которых есть хотя бы один заказ ниже среднего. Финальный SELECT находит тех, кого нет в этом списке. Такая декомпозиция проще для чтения, чем вложенные подзапросы с коррелированными условиями.
4. Напишите рекурсивный CTE: все подчинённые конкретного руководителя.
WITH RECURSIVE subordinates AS (
SELECT id, name, manager_id
FROM employees
WHERE manager_id = 42 -- id руководителя
UNION ALL
SELECT e.id, e.name, e.manager_id
FROM employees e
JOIN subordinates s ON e.manager_id = s.id
)
SELECT * FROM subordinatesЯкорь — прямые подчинённые сотрудника 42. Рекурсия — подчинённые подчинённых, и так далее, пока не закончатся уровни. На собеседовании обязательно спросят: «Что будет, если в данных есть цикл?». Ответ: бесконечная рекурсия. Защита — добавить LIMIT или максимальную глубину через счётчик уровней.
5. Чем CTE отличается от подзапроса?
Функционально — почти ничем: оба дают промежуточный результат. Отличия: CTE имеет имя и может использоваться в запросе несколько раз. Подзапрос нельзя переиспользовать — его придётся дублировать. CTE делает запрос линейным (сверху вниз), подзапросы — вложенным (изнутри наружу). При нескольких уровнях трансформации CTE существенно выигрывает в читаемости.
Как тренироваться
CTE — навык, который нарабатывается практикой. Теоретически понять WITH несложно, но на собеседовании нужно уверенно декомпозировать задачу на шаги и записать каждый шаг как отдельный CTE — без пауз и переписываний.
Тренажёр Карьерник содержит задачи, где нужно использовать CTE: воронки, рекурсивные иерархии, пошаговые трансформации данных. Можно тренироваться по 15 минут в день в Telegram — этого достаточно, чтобы за неделю перестать спотыкаться на декомпозиции запросов.
Больше материалов по SQL — в разделе подготовки. Если хотите разобрать конкретные конструкции, посмотрите шпаргалки по JOIN и оконным функциям.
FAQ
Можно ли использовать CTE в INSERT, UPDATE, DELETE?
Да. WITH работает не только с SELECT. В PostgreSQL можно написать WITH ... INSERT INTO, WITH ... UPDATE, WITH ... DELETE. Это удобно, когда нужно вычислить промежуточный результат и на его основе обновить данные. Например, деактивировать пользователей, не заходивших 90 дней: CTE считает таких пользователей, DELETE удаляет (или UPDATE ставит флаг).
Влияет ли CTE на производительность запроса?
В большинстве случаев — нет. Современные оптимизаторы (PostgreSQL 12+, BigQuery, SQL Server) инлайнят CTE, если он используется один раз. Запрос с CTE и эквивалентный запрос с подзапросом дадут одинаковый план выполнения. Исключение — если CTE используется несколько раз: тогда его выгодно материализовать, чтобы не вычислять повторно. Но если промежуточный результат большой и нужен один раз — инлайн лучше.
Поддерживается ли CTE в MySQL?
Да, начиная с MySQL 8.0 (2018). Рекурсивные CTE тоже поддерживаются. В MySQL 5.7 и ниже WITH недоступен — вместо него используют подзапросы или временные таблицы. На собеседовании обычно пишут на PostgreSQL или не уточняют СУБД, поэтому CTE можно использовать свободно.