CTE vs подзапрос — что лучше и когда что использовать
Коротко
CTE (Common Table Expression) — именованный временный набор данных, определённый через WITH. Подзапрос — вложенный SELECT внутри основного запроса. Оба решают одни и те же задачи, но CTE выигрывает в читаемости, а подзапросы иногда — в производительности.
Как выглядит CTE
WITH active_users AS (
SELECT user_id, COUNT(*) AS sessions
FROM sessions
WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY user_id
)
SELECT u.name, a.sessions
FROM users u
JOIN active_users a ON u.user_id = a.user_id
WHERE a.sessions > 10;CTE определяется один раз и используется по имени — как виртуальная таблица.
Как выглядит подзапрос
SELECT u.name, a.sessions
FROM users u
JOIN (
SELECT user_id, COUNT(*) AS sessions
FROM sessions
WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY user_id
) a ON u.user_id = a.user_id
WHERE a.sessions > 10;Тот же результат, но подзапрос встроен прямо в JOIN.
Ключевые отличия
| CTE (WITH) | Подзапрос | |
|---|---|---|
| Читаемость | Высокая (именованный, линейный) | Ниже (вложенный) |
| Переиспользование | Можно ссылаться несколько раз | Каждый раз писать заново |
| Рекурсия | Поддерживает (WITH RECURSIVE) | Не поддерживает |
| Производительность | Зависит от СУБД | Зависит от СУБД |
| Вложенность | Линейная цепочка | Может быть глубоко вложенным |
Когда CTE лучше
1. Сложные запросы с несколькими шагами
WITH daily_revenue AS (
SELECT DATE_TRUNC('day', created_at) AS day,
SUM(amount) AS revenue
FROM orders
GROUP BY 1
),
weekly_avg AS (
SELECT day, revenue,
AVG(revenue) OVER (ORDER BY day ROWS 6 PRECEDING) AS rolling_avg
FROM daily_revenue
)
SELECT * FROM weekly_avg WHERE revenue > rolling_avg * 1.5;Каждый шаг — отдельный CTE с понятным именем. Читается сверху вниз.
2. Повторное использование
WITH user_stats AS (
SELECT user_id, COUNT(*) AS orders, SUM(amount) AS total
FROM orders GROUP BY user_id
)
SELECT 'high_value' AS segment, COUNT(*) FROM user_stats WHERE total > 10000
UNION ALL
SELECT 'low_value', COUNT(*) FROM user_stats WHERE total <= 10000;user_stats используется дважды. С подзапросами пришлось бы дублировать код.
3. Рекурсия
WITH RECURSIVE org_tree AS (
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 * FROM org_tree;Рекурсивные CTE — единственный способ обходить деревья и графы в SQL.
Когда подзапрос лучше
Простые одноразовые вычисления:
SELECT name, salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);Для таких простых случаев CTE — overkill.
Производительность
В PostgreSQL CTE до версии 12 всегда материализовались (вычислялись отдельно, даже если это неэффективно). С PostgreSQL 12+ оптимизатор может инлайнить CTE как подзапрос.
На собеседовании: если спросят про производительность, скажите: «В современных версиях PostgreSQL разницы обычно нет. Но если CTE используется один раз, оптимизатор может его инлайнить. Если нужно принудительно материализовать — есть подсказка MATERIALIZED.»
Вопросы с собеседований
- «Когда вы используете CTE, а когда подзапрос?» — CTE для сложных многошаговых запросов и переиспользования. Подзапрос для простых одноразовых фильтров.
- «CTE создаёт временную таблицу?» — Нет, это не таблица. CTE существует только во время выполнения запроса.
- «Можно ли в CTE использовать INSERT/UPDATE?» — Да, через writable CTE (WITH ... AS (DELETE ... RETURNING *)).
FAQ
CTE замедляет запрос?
В современных СУБД (PostgreSQL 12+, MySQL 8+) — обычно нет. Оптимизатор инлайнит CTE при необходимости. В старых версиях CTE всегда материализуется, что может быть как плюсом, так и минусом.
Сколько CTE можно использовать в одном запросе?
Без ограничений. WITH a AS (...), b AS (...), c AS (...) SELECT ... — каждый следующий CTE может ссылаться на предыдущие.
CTE — это то же самое, что временная таблица?
Нет. Временная таблица (CREATE TEMP TABLE) хранится на диске и живёт до конца сессии. CTE существует только во время выполнения одного запроса.