CTE и рекурсивные запросы в PostgreSQL
CTE (Common Table Expressions) с оператором WITH — это PostgreSQL-способ писать сложные запросы как последовательность именованных шагов. Вместо глубоко вложенных подзапросов: WITH step1 AS (...), step2 AS (...) SELECT FROM step2. Рекурсивный CTE (WITH RECURSIVE) добавляет возможность обхода иерархических структур и графов прямо в SQL: деревья категорий, org-структуры, цепочки зависимостей. Data-modifying CTE позволяет выполнять DELETE + INSERT атомарно в одном запросе. PostgreSQL 14 добавил встроенную защиту от циклов: CYCLE clause. Освоив CTE, ты пишешь сложные многоуровневые запросы так же читаемо, как обычный код — каждый блок делает одну понятную вещь.
Почему это важно: До CTE сложные аналитические запросы в PostgreSQL превращались в нечитаемые матрёшки из подзапросов — понять такой код через месяц практически невозможно. CTE решают это архитектурно: каждый этап трансформации данных получает имя и становится отдельным читаемым блоком. Рекурсивный CTE решает ещё более серьёзную проблему: без него обход деревьев (категории → подкатегории → подподкатегории неограниченной глубины) требует либо многократных запросов из приложения с накоплением результата, либо хранимой процедуры, либо загрузки всего дерева и обхода в памяти. Рекурсивный CTE делает это одним запросом, который выполняется внутри PostgreSQL — без сетевых round-trips, без памяти приложения.
Главная идея
CTE синтаксис: WITH имя AS (SELECT ...) SELECT ... FROM имя. Несколько CTE: WITH a AS (...), b AS (...) SELECT FROM a JOIN b. CTE доступны только внутри запроса и не создают объектов в БД. Рекурсивный CTE: WITH RECURSIVE имя AS (базовый_случай UNION ALL рекурсивная_часть). Базовый случай выполняется один раз и возвращает начальные строки. Рекурсивная часть использует предыдущий результат как входные данные, генерирует новые строки, повторяется пока есть новые строки. CYCLE id SET is_cycle (PostgreSQL 14+) автоматически обнаруживает циклы в графе. Data-modifying CTE: WITH moved AS (DELETE FROM staging RETURNING *) INSERT INTO production SELECT * FROM moved. Изменения атомарны. Materialization: WITH cte AS MATERIALIZED (...) / NOT MATERIALIZED (...) — явное управление тем, вычисляется ли CTE один раз или встраивается в план.
Как это выглядит на практике
- Задача: построить страницу профиля клиента с суммой заказов, количеством, средним чеком и последней покупкой. Первое решение: 4 отдельных запроса в Ruby. Понимаешь что это неэффективно — переносишь в CTE.
- Пишешь WITH order_stats AS (SELECT COUNT(*), SUM(amount), AVG(amount), MAX(created_at) FROM orders WHERE user_id = $1 GROUP BY user_id). Один запрос, один round-trip, результат совпадает.
- Появляется требование: показать дерево категорий для сайдбара магазина. Категории вложены на 4-5 уровней. Первое решение в коде: рекурсивная функция, которая делает SELECT для каждого узла — 200+ запросов при 200 категориях.
- Узнаёшь про рекурсивный CTE. Пишешь WITH RECURSIVE cats AS (корень UNION ALL дочерние). Один запрос возвращает всё дерево — 200 узлов за 3ms вместо 500ms и 200 round-trips.
- Появляется задача перенести обработанные заказы из staging-таблицы в основную и одновременно залогировать операцию. Нужна атомарность. Data-modifying CTE: WITH deleted AS (DELETE FROM staging RETURNING *) INSERT INTO orders SELECT * FROM deleted, INSERT INTO log SELECT id, NOW() FROM deleted.
- На code review замечаешь что коллега написал запрос с 5 уровнями вложенных подзапросов. Показываешь как переписать в CTE — код уменьшается вдвое и становится читаемым.
- Нужно найти всех подчинённых сотрудника в org-структуре (произвольная глубина). Рекурсивный CTE с накоплением пути: ancestors || employee_id позволяет построить полную иерархию.
- Обнаруживаешь медленный запрос: CTE используется дважды в запросе, но PostgreSQL вычисляет его дважды (NOT MATERIALIZED в Postgres 12+). Добавляешь MATERIALIZED — CTE вычисляется один раз, производительность растёт в 2 раза.
- Задача: для каждого товара найти все родительские категории (breadcrumbs). Рекурсивный CTE снизу вверх: начинаешь с category_id товара, идёшь по parent_id до корня. Массив ancestors || id накапливает путь.
- Интеграция с внешней системой: массовый upsert пользователей из CSV. Data-modifying CTE с ON CONFLICT DO UPDATE + RETURNING (xmax = 0) позволяет в одном запросе узнать сколько создано и сколько обновлено.
- Генерация серии дат для отчёта: WITH RECURSIVE dates AS (SELECT '2024-01-01'::DATE AS d UNION ALL SELECT d + 1 FROM dates WHERE d < '2024-01-31') SELECT d FROM dates — все даты января без generate_series.
- На собеседовании просят написать запрос для поиска всех возможных путей в графе между двумя узлами. Рекурсивный CTE с накоплением visited nodes и проверкой на циклы через CYCLE clause.
Примеры кода
CTE-цепочка: разбивка сложной логики на шаги
-- Отчёт: топ клиенты за квартал с их сегментом и регионом
WITH
-- Шаг 1: заказы за текущий квартал
quarterly_orders AS (
SELECT
user_id,
COUNT(*) AS order_count,
SUM(amount) AS total_spent,
MAX(created_at) AS last_order_at
FROM orders
WHERE created_at >= DATE_TRUNC('quarter', NOW())
AND status = 'completed'
GROUP BY user_id
),
-- Шаг 2: определяем сегмент клиента
segmented AS (
SELECT
user_id,
order_count,
total_spent,
last_order_at,
CASE
WHEN total_spent >= 100000 THEN 'VIP'
WHEN total_spent >= 30000 THEN 'Premium'
WHEN total_spent >= 10000 THEN 'Regular'
ELSE 'New'
END AS segment
FROM quarterly_orders
),
-- Шаг 3: ранжируем внутри сегмента
ranked AS (
SELECT
s.*,
u.email,
u.name,
u.country,
ROW_NUMBER() OVER (PARTITION BY s.segment ORDER BY s.total_spent DESC) AS rank_in_segment
FROM segmented s
JOIN users u ON u.id = s.user_id
WHERE u.is_active = true
)
-- Финальный результат: топ-3 в каждом сегменте
SELECT *
FROM ranked
WHERE rank_in_segment <= 3
ORDER BY segment, rank_in_segment;
Каждый CTE — один чёткий шаг: фильтрация → сегментация → ранжирование. Читается как спецификация задачи. PostgreSQL 12+ может встроить (inline) простые CTE в основной план для оптимизации. Этот запрос заменяет 3-4 запроса в Ruby с merge в хэш.
Рекурсивный CTE: обход дерева категорий
-- Таблица: categories(id, name, slug, parent_id)
-- Задача: получить полное поддерево для категории 'electronics'
WITH RECURSIVE category_tree AS (
-- Базовый случай: корневая категория
SELECT
id,
name,
slug,
parent_id,
0 AS depth,
name::TEXT AS full_path,
ARRAY[id] AS ancestors
FROM categories
WHERE slug = 'electronics'
UNION ALL
-- Рекурсивная часть: дочерние категории текущего уровня
SELECT
c.id,
c.name,
c.slug,
c.parent_id,
ct.depth + 1,
ct.full_path || ' > ' || c.name,
ct.ancestors || c.id
FROM categories c
JOIN category_tree ct ON c.parent_id = ct.id
WHERE ct.depth < 8 -- ограничение глубины
)
SELECT
id,
name,
depth,
full_path,
-- Можно подсчитать товары в каждой ветке
(SELECT COUNT(*) FROM products p WHERE p.category_id = category_tree.id AND p.is_active) AS product_count
FROM category_tree
ORDER BY full_path;
-- PostgreSQL 14+: встроенная защита от циклов (для графов с циклами)
WITH RECURSIVE graph_traversal AS (
SELECT id, parent_id, name, 0 AS depth
FROM nodes WHERE id = 1
UNION ALL
SELECT n.id, n.parent_id, n.name, gt.depth + 1
FROM nodes n
JOIN graph_traversal gt ON n.parent_id = gt.id
WHERE gt.depth < 20
)
CYCLE id SET is_cycle TO true DEFAULT false USING path
SELECT id, name, depth, is_cycle, path
FROM graph_traversal
WHERE NOT is_cycle;
Рекурсивный CTE выполняется итеративно: базовый случай → рекурсия до исчерпания. ARRAY[id] || c.id накапливает путь от корня — полезно для breadcrumbs. Условие depth < 8 — защита от бесконечного цикла при цикличных данных. PostgreSQL 14 добавил CYCLE clause для автоматического обнаружения циклов в графах.
Data-modifying CTE и materialization
-- Атомарный перенос данных: из staging в production + логирование
WITH
moved_orders AS (
DELETE FROM orders_staging
WHERE processed_at IS NULL
AND created_at < NOW() - INTERVAL '1 hour'
RETURNING *
),
inserted AS (
INSERT INTO orders (user_id, amount, status, created_at)
SELECT user_id, amount, 'pending', created_at
FROM moved_orders
RETURNING id, amount
)
INSERT INTO order_processing_log (order_id, amount, processed_at, source)
SELECT id, amount, NOW(), 'staging_transfer'
FROM inserted;
-- Upsert с возвратом результата в CTE для дальнейшей обработки
WITH upserted_users AS (
INSERT INTO users (email, name, source)
VALUES
('alice@example.com', 'Alice', 'import'),
('bob@example.com', 'Bob', 'import')
ON CONFLICT (email) DO UPDATE
SET name = EXCLUDED.name,
updated_at = NOW()
RETURNING id, email, (xmax = 0) AS is_new
)
SELECT
email,
CASE WHEN is_new THEN 'created' ELSE 'updated' END AS action
FROM upserted_users;
-- Управление материализацией CTE
-- NOT MATERIALIZED: CTE встраивается в план, оптимизатор может использовать индексы
WITH expensive_filter AS NOT MATERIALIZED (
SELECT * FROM orders WHERE user_id = $1 AND status = 'completed'
)
SELECT * FROM expensive_filter WHERE amount > 1000;
-- MATERIALIZED: CTE вычисляется один раз, результат кешируется
-- Полезно когда CTE используется несколько раз в запросе
WITH order_stats AS MATERIALIZED (
SELECT user_id, SUM(amount) AS total, COUNT(*) AS cnt
FROM orders GROUP BY user_id
)
SELECT u.name, os.total, os.cnt
FROM users u
JOIN order_stats os ON u.id = os.user_id
WHERE os.total > 50000
UNION ALL
SELECT 'TOTAL', SUM(os.total), SUM(os.cnt)
FROM order_stats os;
Data-modifying CTE делает DELETE+INSERT атомарным — либо оба успевают, либо ни один. Трюк (xmax = 0) AS is_new в RETURNING позволяет отличить INSERT от UPDATE при ON CONFLICT. MATERIALIZED гарантирует однократное вычисление при нескольких ссылках на CTE — без него планировщик может выполнить CTE дважды.
Что происходит под капотом
- PostgreSQL 12+ изменил поведение CTE: по умолчанию CTE теперь NOT MATERIALIZED если он используется один раз и не рекурсивный. Это означает что планировщик может встроить CTE в основной план и использовать индексы, как для обычного подзапроса.
- Явная материализация: WITH cte AS MATERIALIZED (...) — результат сохраняется во временной структуре данных (analogous to temp table). Полезно когда CTE дорогой и используется несколько раз — без MATERIALIZED планировщик может выполнить его несколько раз.
- Рекурсивный CTE всегда MATERIALIZED — планировщик не может встроить его в основной план. Каждая итерация использует WorkTable, которая обновляется на каждом шаге рекурсии.
- EXPLAIN ANALYZE для CTE: MATERIALIZED CTE виден как CTE Scan в дереве плана. NOT MATERIALIZED CTE встроен и не виден как отдельный узел. Это полезно для диагностики производительности.
- Data-modifying CTE и snapshot isolation: все части запроса видят снимок данных на момент начала запроса. Если CTE-A удаляет строки, а CTE-B читает ту же таблицу — CTE-B видит данные до удаления CTE-A. Изменения становятся видимы только после завершения всего запроса.
- CYCLE detection (PostgreSQL 14+): CYCLE id SET is_cycle TO true DEFAULT false USING path. PostgreSQL автоматически отслеживает посещённые id и устанавливает is_cycle = true при повторном посещении. path — массив пройденных узлов. До PostgreSQL 14: ручная защита через WHERE depth < N или ARRAY проверку.
- Рекурсивный CTE vs ltree extension: ltree хранит иерархические пути как '1.5.12.8' и поддерживает мощные операторы запросов. Для статичных деревьев с частыми запросами — ltree быстрее. Для динамичных деревьев с частыми изменениями структуры — рекурсивный CTE гибче.
- Производительность рекурсии: каждая итерация = Hash Join или Nested Loop с WorkTable. Для дерева глубиной 10 и branching factor 5 — 5^10 потенциальных строк. Всегда ограничивай WHERE depth < N.
Типичные ошибки и заблуждения
- Ошибка: CTE всегда быстрее подзапроса потому что 'оптимизирован'. До PostgreSQL 12 CTE был optimization fence — всегда материализовался и блокировал push-down предикатов. С PostgreSQL 12+ поведение изменилось. Всегда проверяй EXPLAIN ANALYZE.
- Ошибка: рекурсивный CTE безопасен по умолчанию. Без защиты (depth < N или CYCLE clause) рекурсивный CTE на данных с циклами (A → B → A) уйдёт в бесконечный цикл и исчерпает память. Всегда добавляй защиту.
- Ошибка: data-modifying CTE видит свои же изменения в следующем CTE. Snapshot isolation: все CTE в запросе видят данные на момент начала запроса. CTE-A удалил строки — CTE-B по-прежнему их видит.
- Ошибка: CTE на который есть несколько ссылок вычисляется один раз автоматически. Без MATERIALIZED планировщик может вычислить CTE для каждой ссылки отдельно (если решит что inlining выгоднее). Явный MATERIALIZED гарантирует однократное вычисление.
- Ошибка: UNION ALL в рекурсивном CTE можно заменить UNION для дедупликации. UNION не поддерживается в рекурсивном CTE — только UNION ALL. Дедупликацию делай в финальном SELECT или через CYCLE.
- Ошибка: рекурсивный CTE хорош для любой иерархии любого размера. При дереве в миллионы узлов рекурсивный CTE может быть медленным. Альтернативы: ltree extension, materialized path, nested sets — для статичных деревьев с частым чтением.
Ключевые выводы
- CTE = именованные шаги запроса, как переменные в коде — читаемость и переиспользование.
- Рекурсивный CTE: базовый случай UNION ALL рекурсивная часть — итерация до отсутствия новых строк.
- Всегда защищай рекурсию: WHERE depth < N или CYCLE clause (PostgreSQL 14+).
- Data-modifying CTE: DELETE/INSERT/UPDATE внутри WITH — атомарные сложные операции.
- MATERIALIZED / NOT MATERIALIZED — явное управление кешированием CTE при нескольких ссылках.
Термины урока
Связь с работой backend-разработчика
Три главных применения CTE в backend: читаемые многошаговые аналитические запросы (вместо nested subqueries), обход иерархических структур (категории, org-tree, зависимости), атомарные data-modifying операции (DELETE old + INSERT new). Рекурсивный CTE — стандартное решение для дерева категорий: один запрос вместо рекурсивной функции с N round-trips к БД.
Мини-разбор реальной ситуации
E-commerce: страница категории показывает breadcrumbs (путь от корня), все подкатегории с количеством товаров, и рекомендуемые товары из всего поддерева. Первая реализация: 3 отдельных запроса в контроллере, рекурсивная функция в Ruby для breadcrumbs (по одному SELECT на уровень = 5 запросов), 800ms суммарно. Переписано: два рекурсивных CTE — один строит путь снизу вверх (breadcrumbs), другой строит поддерево сверху вниз (подкатегории). Финальный SELECT JOIN-ит товары из поддерева. Три запроса превратились в один — 35ms.
Что запомнить
- Рекурсивный CTE = базовый случай UNION ALL рекурсивная часть. Всегда WHERE depth < N.
- MATERIALIZED гарантирует однократное вычисление при нескольких ссылках на CTE.
- Data-modifying CTE видит только снимок данных ДО начала запроса — snapshot isolation.
Итог
CTE — это способ писать SQL как последовательность понятных шагов. Рекурсивный CTE решает целый класс задач (иерархии, графы) без выноса логики в код приложения. Data-modifying CTE делает атомарными сложные операции типа 'переместить + залогировать'. Освоив эти инструменты, ты пишешь запросы, которые читаются как документация к задаче.