CTE — это временный именованный результат внутри одного SQL-запроса. Его создают через WITH ... AS, а потом используют как обычную таблицу.
Один CTE уже делает запрос понятнее. Но настоящая сила начинается тогда, когда CTE несколько: один подготовил данные, второй их отфильтровал, третий посчитал агрегаты, четвёртый собрал финальный отчёт.
Получается не огромный клубок из вложенных подзапросов, а аккуратная цепочка шагов. Почти как в обычном коде:
- Взяли исходные данные.
- Почистили и отфильтровали.
- Посчитали нужные показатели.
- Отобрали важное.
- Вывели финальный результат.
В SQL это можно красиво записать через несколько CTE.
Зачем нужно несколько CTE
Реальные аналитические запросы редко помещаются в один простой SELECT.
Обычно задача выглядит так:
- сначала соединить несколько таблиц;
- потом оставить только нужные строки;
- потом посчитать метрики;
- потом сравнить их между собой;
- потом выбрать топ, анти-топ или строки с проблемами;
- потом красиво вывести результат.
Если всё это запихнуть в один большой вложенный запрос, его будет тяжело читать, проверять и чинить.
Несколько CTE позволяют разложить логику на понятные этапы.
Например, не так:
SELECT ...
FROM (
SELECT ...
FROM (
SELECT ...
FROM ...
) AS t1
) AS t2;
А так:
WITH
step_1 AS (
SELECT ...
),
step_2 AS (
SELECT ...
FROM step_1
),
step_3 AS (
SELECT ...
FROM step_2
)
SELECT ...
FROM step_3;
Второй вариант читается как инструкция. У каждого шага есть имя, а имя объясняет смысл.
Базовый синтаксис нескольких CTE
Если CTE несколько, WITH пишется один раз в самом начале запроса.
Дальше CTE перечисляются через запятую.
WITH
first_step AS (
SELECT ...
FROM source_table
),
second_step AS (
SELECT ...
FROM first_step
),
third_step AS (
SELECT ...
FROM second_step
)
SELECT ...
FROM third_step;
Важные правила:
WITH пишется один раз;
- каждый CTE имеет имя;
- после имени идёт
AS (...);
- CTE отделяются друг от друга запятыми;
- финальный
SELECT идёт после всех CTE;
- следующий CTE может ссылаться на предыдущие;
- предыдущий CTE не может ссылаться на следующий.
То есть порядок важен.
Так можно:
WITH
paid_orders AS (
SELECT *
FROM orders
WHERE status = 'paid'
),
revenue_by_customer AS (
SELECT
customer_id,
SUM(amount) AS total_revenue
FROM paid_orders
GROUP BY customer_id
)
SELECT *
FROM revenue_by_customer;
А так нельзя:
WITH
revenue_by_customer AS (
SELECT
customer_id,
SUM(amount) AS total_revenue
FROM paid_orders
GROUP BY customer_id
),
paid_orders AS (
SELECT *
FROM orders
WHERE status = 'paid'
)
SELECT *
FROM revenue_by_customer;
Первый CTE пытается использовать paid_orders, но тот объявлен ниже. Для SQL это всё равно что обратиться к переменной до её создания.
Как читать цепочку CTE
Хорошая цепочка CTE читается сверху вниз.
Например:
WITH
paid_orders AS (
SELECT *
FROM orders
WHERE status = 'paid'
),
customer_totals AS (
SELECT
customer_id,
COUNT(*) AS orders_count,
SUM(amount) AS total_amount
FROM paid_orders
GROUP BY customer_id
)
SELECT
customer_id,
orders_count,
total_amount
FROM customer_totals
ORDER BY total_amount DESC;
Читать можно так:
paid_orders — берём только оплаченные заказы.
customer_totals — считаем по каждому клиенту количество заказов и сумму.
- Финальный
SELECT — выводим результат и сортируем по сумме.
Главная польза CTE в том, что каждый шаг можно понять отдельно. Не нужно держать в голове весь запрос целиком.
Пример: топ-3 заказа каждого клиента
Допустим, есть таблица orders.
| id |
customer_id |
amount |
created_at |
| 1 |
10 |
500 |
2026-01-01 |
| 2 |
10 |
1200 |
2026-01-03 |
| 3 |
10 |
700 |
2026-01-05 |
| 4 |
10 |
2000 |
2026-01-10 |
| 5 |
20 |
300 |
2026-01-02 |
| 6 |
20 |
900 |
2026-01-08 |
| 7 |
20 |
100 |
2026-01-12 |
И есть таблица customers.
Задача: для каждого клиента взять его 3 самых дорогих заказа, а потом посчитать средний чек среди этих топ-3 заказов.
Запрос через несколько CTE:
WITH
ranked_orders AS (
SELECT
customer_id,
amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY amount DESC, id ASC
) AS rn
FROM orders
),
top_orders AS (
SELECT
customer_id,
amount
FROM ranked_orders
WHERE rn <= 3
),
customer_avg AS (
SELECT
customer_id,
AVG(amount) AS avg_top_amount
FROM top_orders
GROUP BY customer_id
)
SELECT
c.id,
c.name,
ca.avg_top_amount
FROM customers AS c
JOIN customer_avg AS ca
ON ca.customer_id = c.id
ORDER BY ca.avg_top_amount DESC;
Разберём по шагам.
Шаг 1: пронумеровать заказы клиента
Первый CTE:
WITH
ranked_orders AS (
SELECT
customer_id,
amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY amount DESC, id ASC
) AS rn
FROM orders
)
SELECT *
FROM ranked_orders;
Он берёт заказы и нумерует их внутри каждого клиента.
PARTITION BY customer_id означает: для каждого клиента отдельная нумерация.
ORDER BY amount DESC, id ASC означает: сначала самые дорогие заказы. Если суммы одинаковые, порядок стабилизируется по id.
Результат может быть таким:
| customer_id |
amount |
rn |
| 10 |
2000 |
1 |
| 10 |
1200 |
2 |
| 10 |
700 |
3 |
| 10 |
500 |
4 |
| 20 |
900 |
1 |
| 20 |
300 |
2 |
| 20 |
100 |
3 |
Пока мы ничего не агрегируем. Только добавляем номер заказа в рейтинге клиента.
Шаг 2: оставить только топ-3
Второй CTE использует результат первого.
WITH
ranked_orders AS (
SELECT
customer_id,
amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY amount DESC, id ASC
) AS rn
FROM orders
),
top_orders AS (
SELECT
customer_id,
amount
FROM ranked_orders
WHERE rn <= 3
)
SELECT *
FROM top_orders;
Теперь остаются только заказы с rn <= 3.
Для клиента 10 заказ на 500 исчезнет, потому что он четвёртый по сумме.
| customer_id |
amount |
| 10 |
2000 |
| 10 |
1200 |
| 10 |
700 |
| 20 |
900 |
| 20 |
300 |
| 20 |
100 |
Этот шаг делает ровно одну вещь: оставляет топовые заказы.
Шаг 3: посчитать среднее по топ-3
Третий CTE берёт top_orders и считает среднюю сумму.
WITH
ranked_orders AS (
SELECT
customer_id,
amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY amount DESC, id ASC
) AS rn
FROM orders
),
top_orders AS (
SELECT
customer_id,
amount
FROM ranked_orders
WHERE rn <= 3
),
customer_avg AS (
SELECT
customer_id,
AVG(amount) AS avg_top_amount
FROM top_orders
GROUP BY customer_id
)
SELECT *
FROM customer_avg;
Результат:
| customer_id |
avg_top_amount |
| 10 |
1300 |
| 20 |
433.3333333333333333 |
Для клиента 10 среднее считается по суммам 2000, 1200, 700.
Для клиента 20 — по суммам 900, 300, 100.
Финальный SELECT: добавить имена клиентов
Последний запрос уже не делает сложных расчётов. Он просто соединяет готовый результат с таблицей клиентов.
WITH
ranked_orders AS (
SELECT
customer_id,
amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY amount DESC, id ASC
) AS rn
FROM orders
),
top_orders AS (
SELECT
customer_id,
amount
FROM ranked_orders
WHERE rn <= 3
),
customer_avg AS (
SELECT
customer_id,
AVG(amount) AS avg_top_amount
FROM top_orders
GROUP BY customer_id
)
SELECT
c.id,
c.name,
ca.avg_top_amount
FROM customers AS c
JOIN customer_avg AS ca
ON ca.customer_id = c.id
ORDER BY ca.avg_top_amount DESC;
Результат:
| id |
name |
avg_top_amount |
| 10 |
Alice |
1300 |
| 20 |
Bob |
433.3333333333333333 |
Запрос получился длинный, но не страшный. Потому что он разбит на понятные части.
Каждый CTE отвечает за один этап:
| CTE |
Что делает |
ranked_orders |
Нумерует заказы клиента по сумме |
top_orders |
Оставляет только первые 3 заказа |
customer_avg |
Считает среднее по этим заказам |
финальный SELECT |
Добавляет имя клиента и сортирует результат |
Вот это и есть хороший стиль работы с CTE.
Почему не написать всё одним подзапросом
Технически тот же запрос можно написать без CTE.
SELECT
c.id,
c.name,
ca.avg_top_amount
FROM customers AS c
JOIN (
SELECT
customer_id,
AVG(amount) AS avg_top_amount
FROM (
SELECT
customer_id,
amount
FROM (
SELECT
customer_id,
amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY amount DESC, id ASC
) AS rn
FROM orders
) AS ranked_orders
WHERE rn <= 3
) AS top_orders
GROUP BY customer_id
) AS ca
ON ca.customer_id = c.id
ORDER BY ca.avg_top_amount DESC;
Он делает то же самое. Но читать его намного тяжелее.
Когда подзапросы вложены друг в друга, приходится идти изнутри наружу, постоянно помнить, где какой уровень. Ошибся скобкой — и весь запрос превращается в квест.
CTE делает то же самое более спокойно:
WITH
ranked_orders AS (...),
top_orders AS (...),
customer_avg AS (...)
SELECT ...
FROM customer_avg;
Для учебных задач, аналитики, отчётов и ревью кода CTE обычно понятнее.
Один CTE можно использовать несколько раз
CTE полезен не только как шаг цепочки. Иногда один и тот же промежуточный результат нужен в запросе несколько раз.
Например, хотим посчитать выручку по месяцам и сравнить каждый месяц с предыдущим.
Есть таблица orders.
| id |
amount |
status |
created_at |
| 1 |
1000 |
paid |
2026-01-05 |
| 2 |
1500 |
paid |
2026-01-20 |
| 3 |
800 |
canceled |
2026-01-21 |
| 4 |
2000 |
paid |
2026-02-10 |
| 5 |
2500 |
paid |
2026-03-12 |
Сначала удобно собрать месячную выручку в CTE.
WITH
monthly_revenue AS (
SELECT
DATE_TRUNC('month', created_at) AS month_start,
SUM(amount) AS total_amount
FROM orders
WHERE status = 'paid'
GROUP BY DATE_TRUNC('month', created_at)
)
SELECT
current_month.month_start,
current_month.total_amount,
previous_month.total_amount AS previous_total_amount,
current_month.total_amount - previous_month.total_amount AS amount_diff
FROM monthly_revenue AS current_month
LEFT JOIN monthly_revenue AS previous_month
ON previous_month.month_start = current_month.month_start - INTERVAL '1 month'
ORDER BY current_month.month_start;
Здесь monthly_revenue используется дважды:
- как
current_month;
- как
previous_month.
Это обычный self join: мы соединяем результат сам с собой, чтобы рядом с текущим месяцем увидеть предыдущий.
Без CTE пришлось бы писать одну и ту же агрегацию два раза. А так мы один раз дали ей имя monthly_revenue и спокойно переиспользовали.
CTE как способ отладки запроса
Большой плюс CTE — их удобно проверять по одному.
Допустим, финальный запрос не даёт ожидаемый результат. Не нужно сразу разбирать всю конструкцию.
Можно временно заменить финальный SELECT и посмотреть промежуточный шаг.
Например, проверить первый CTE:
WITH
ranked_orders AS (
SELECT
customer_id,
amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY amount DESC, id ASC
) AS rn
FROM orders
),
top_orders AS (
SELECT
customer_id,
amount
FROM ranked_orders
WHERE rn <= 3
),
customer_avg AS (
SELECT
customer_id,
AVG(amount) AS avg_top_amount
FROM top_orders
GROUP BY customer_id
)
SELECT *
FROM ranked_orders
ORDER BY customer_id, rn;
Потом проверить второй:
WITH
ranked_orders AS (
SELECT
customer_id,
amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY amount DESC, id ASC
) AS rn
FROM orders
),
top_orders AS (
SELECT
customer_id,
amount
FROM ranked_orders
WHERE rn <= 3
),
customer_avg AS (
SELECT
customer_id,
AVG(amount) AS avg_top_amount
FROM top_orders
GROUP BY customer_id
)
SELECT *
FROM top_orders
ORDER BY customer_id, amount DESC;
Так проще найти ошибку. Если ranked_orders уже неправильный, нет смысла смотреть финальный отчёт. Нужно чинить первый шаг.
CTE делает SQL-запрос похожим на конвейер, где каждый участок можно проверить отдельно.
Хорошие имена для CTE
Имя CTE должно отвечать на вопрос: «что лежит внутри?»
Плохие имена:
WITH
t1 AS (...),
t2 AS (...),
t3 AS (...)
SELECT *
FROM t3;
Такие имена ничего не объясняют. Через неделю ты сам забудешь, что такое t2.
Лучше:
WITH
paid_orders AS (...),
orders_by_customer AS (...),
top_customers AS (...)
SELECT *
FROM top_customers;
Хорошие имена не обязаны быть короткими. Они должны быть понятными.
Например:
paid_orders;
active_users;
monthly_revenue;
ranked_orders;
top_products;
customers_without_orders;
orders_with_items;
revenue_by_category.
Когда CTE названы хорошо, запрос можно понять почти по одним названиям.
Когда несколько CTE особенно полезны
Несколько CTE хорошо подходят для задач, где есть последовательная обработка.
Например: «активные пользователи без заказов за последний месяц».
WITH
active_users AS (
SELECT
id,
email
FROM users
WHERE is_active = true
),
recent_orders AS (
SELECT
id,
user_id
FROM orders
WHERE created_at >= DATE '2026-01-01'
AND created_at < DATE '2026-02-01'
),
users_without_orders AS (
SELECT
au.id,
au.email
FROM active_users AS au
LEFT JOIN recent_orders AS ro
ON ro.user_id = au.id
WHERE ro.id IS NULL
)
SELECT
id,
email
FROM users_without_orders
ORDER BY id;
Что здесь происходит:
active_users — берём активных пользователей.
recent_orders — берём заказы за нужный период.
users_without_orders — оставляем активных пользователей, у которых нет заказов.
- Финальный
SELECT — выводим список.
Можно было написать короче, но так логика видна сразу.
CTE и порядок выполнения: важное уточнение
Новички часто думают, что база обязательно выполняет CTE строго как отдельные временные таблицы: сначала первый, потом второй, потом третий.
Логически запрос действительно читается сверху вниз. Но физически оптимизатор базы может выполнить его иначе, если так быстрее.
В PostgreSQL начиная с версии 12 простые CTE часто могут быть встроены в общий план запроса. То есть база не всегда обязана материализовать CTE как отдельную временную таблицу.
Для новичка это значит простую вещь: CTE в первую очередь нужен для читаемости и структуры запроса. А производительность нужно проверять на реальном плане выполнения, если запрос тяжёлый.
MATERIALIZED и NOT MATERIALIZED в PostgreSQL
В PostgreSQL можно подсказать базе, что делать с CTE: материализовать его или попробовать встроить в основной запрос.
Материализовать — значит посчитать CTE отдельно и сохранить промежуточный результат на время выполнения запроса.
WITH
paid_orders AS MATERIALIZED (
SELECT *
FROM orders
WHERE status = 'paid'
)
SELECT *
FROM paid_orders;
Можно и наоборот попросить не материализовать CTE, если это возможно:
WITH
paid_orders AS NOT MATERIALIZED (
SELECT *
FROM orders
WHERE status = 'paid'
)
SELECT *
FROM paid_orders;
Для начинающего это не самая первая тема. В обычных запросах чаще достаточно писать простой CTE без этих слов.
Но важно знать: CTE — это не всегда «железная временная таблица». Современная база может оптимизировать запрос умнее.
Рекурсивные CTE
Обычные CTE идут по цепочке: один использует предыдущий.
Но есть особый вид CTE — рекурсивный. Он может ссылаться сам на себя.
Рекурсивные CTE используют для иерархий:
- дерево сотрудников и руководителей;
- категории товаров с вложенными подкатегориями;
- комментарии с ответами;
- маршруты;
- связи в графах.
Допустим, есть таблица сотрудников.
| id |
name |
manager_id |
| 1 |
Alice |
NULL |
| 2 |
Bob |
1 |
| 3 |
Carol |
1 |
| 4 |
Dave |
2 |
| 5 |
Eve |
2 |
manager_id указывает на руководителя. У Alice руководителя нет, она на верхнем уровне.
Хотим получить оргструктуру с уровнями.
WITH RECURSIVE org_chart 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,
oc.level + 1 AS level
FROM employees AS e
JOIN org_chart AS oc
ON e.manager_id = oc.id
)
SELECT
id,
name,
manager_id,
level
FROM org_chart
ORDER BY level, id;
Результат:
| id |
name |
manager_id |
level |
| 1 |
Alice |
NULL |
1 |
| 2 |
Bob |
1 |
2 |
| 3 |
Carol |
1 |
2 |
| 4 |
Dave |
2 |
3 |
| 5 |
Eve |
2 |
3 |
Как работает рекурсивный CTE
Рекурсивный CTE состоит из двух частей.
Первая часть — базовый случай.
SELECT
id,
name,
manager_id,
1 AS level
FROM employees
WHERE manager_id IS NULL
Она выбирает верхний уровень: сотрудников без руководителя.
Вторая часть — рекурсивный шаг.
SELECT
e.id,
e.name,
e.manager_id,
oc.level + 1 AS level
FROM employees AS e
JOIN org_chart AS oc
ON e.manager_id = oc.id
Она берёт сотрудников, чей руководитель уже найден на предыдущем шаге.
UNION ALL соединяет базовый случай и все следующие шаги.
База повторяет рекурсивный шаг до тех пор, пока появляются новые строки.
Ключевое слово RECURSIVE обязательно. Без него CTE не сможет ссылаться сам на себя.
Защита от бесконечной рекурсии
С рекурсивными CTE нужно быть осторожным. Если в данных есть цикл или условие написано неправильно, запрос может уйти слишком глубоко.
Например, сотрудник 1 руководит сотрудником 2, а сотрудник 2 почему-то руководит сотрудником 1. Получается круг.
Чтобы защититься, часто добавляют ограничение глубины.
WITH RECURSIVE org_chart 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,
oc.level + 1 AS level
FROM employees AS e
JOIN org_chart AS oc
ON e.manager_id = oc.id
WHERE oc.level < 10
)
SELECT
id,
name,
manager_id,
level
FROM org_chart
ORDER BY level, id;
Условие:
WHERE oc.level < 10
не даёт рекурсии уйти глубже 10 уровней.
Это не заменяет правильную модель данных, но помогает защититься от неприятных сюрпризов.
Несколько CTE и финальный INSERT
CTE можно использовать не только перед SELECT.
Например, можно подготовить данные через несколько CTE, а потом вставить результат в другую таблицу.
WITH
paid_orders AS (
SELECT
id,
customer_id,
amount,
created_at
FROM orders
WHERE status = 'paid'
),
monthly_revenue AS (
SELECT
DATE_TRUNC('month', created_at) AS month_start,
SUM(amount) AS total_amount
FROM paid_orders
GROUP BY DATE_TRUNC('month', created_at)
)
INSERT INTO revenue_report (month_start, total_amount)
SELECT
month_start,
total_amount
FROM monthly_revenue;
Здесь CTE готовят данные для вставки в таблицу revenue_report.
Это удобно для отчётных таблиц, миграций и backfill-задач.
Несколько CTE и финальный UPDATE
CTE можно использовать и перед UPDATE.
Например, хотим найти клиентов с большой суммой заказов и обновить им сегмент.
WITH
customer_totals AS (
SELECT
customer_id,
SUM(amount) AS total_amount
FROM orders
WHERE status = 'paid'
GROUP BY customer_id
),
vip_customers AS (
SELECT
customer_id
FROM customer_totals
WHERE total_amount >= 100000
)
UPDATE customers AS c
SET segment = 'vip'
FROM vip_customers AS vc
WHERE vc.customer_id = c.id;
Смысл такой:
customer_totals считает сумму заказов.
vip_customers выбирает клиентов с большой суммой.
UPDATE обновляет этих клиентов.
Для сложных обновлений CTE часто делает запрос намного понятнее и безопаснее.
Несколько CTE и финальный DELETE
CTE может помочь аккуратно удалить ограниченное количество строк.
Например, в PostgreSQL нет простого синтаксиса DELETE ... LIMIT, но можно выбрать нужные id через CTE.
WITH
old_logs AS (
SELECT
id
FROM logs
WHERE created_at < DATE '2024-01-01'
ORDER BY id
LIMIT 1000
)
DELETE FROM logs
WHERE id IN (
SELECT id
FROM old_logs
);
Такой подход полезен, когда нужно удалять данные небольшими порциями, а не сносить огромный объём за один раз.
Когда CTE лучше не использовать
CTE — отличный инструмент, но не нужно оборачивать в него каждый маленький запрос.
Если запрос простой, CTE может только добавить лишний шум.
Например, так можно, но смысла мало:
WITH
active_users AS (
SELECT
id,
email
FROM users
WHERE is_active = true
)
SELECT
id,
email
FROM active_users;
Проще написать:
SELECT
id,
email
FROM users
WHERE is_active = true;
CTE особенно полезен, когда:
- запрос длинный;
- есть несколько логических шагов;
- промежуточный результат нужен несколько раз;
- нужно использовать оконную функцию, а потом отфильтровать её результат;
- нужно улучшить читаемость;
- нужно безопасно подготовить данные для
INSERT, UPDATE или DELETE.
Если запрос и так читается легко, CTE не обязателен.
Частые ошибки с несколькими CTE
Писать WITH перед каждым CTE
Неправильно:
WITH a AS (
SELECT *
FROM orders
)
WITH b AS (
SELECT *
FROM a
)
SELECT *
FROM b;
Правильно:
WITH
a AS (
SELECT *
FROM orders
),
b AS (
SELECT *
FROM a
)
SELECT *
FROM b;
WITH один, CTE несколько, между ними запятые.
Забыть запятую между CTE
Неправильно:
WITH
paid_orders AS (
SELECT *
FROM orders
WHERE status = 'paid'
)
customer_totals AS (
SELECT
customer_id,
SUM(amount) AS total_amount
FROM paid_orders
GROUP BY customer_id
)
SELECT *
FROM customer_totals;
Между paid_orders и customer_totals нужна запятая.
Правильно:
WITH
paid_orders AS (
SELECT *
FROM orders
WHERE status = 'paid'
),
customer_totals AS (
SELECT
customer_id,
SUM(amount) AS total_amount
FROM paid_orders
GROUP BY customer_id
)
SELECT *
FROM customer_totals;
Сослаться на CTE, который объявлен ниже
Неправильно:
WITH
customer_totals AS (
SELECT
customer_id,
SUM(amount) AS total_amount
FROM paid_orders
GROUP BY customer_id
),
paid_orders AS (
SELECT *
FROM orders
WHERE status = 'paid'
)
SELECT *
FROM customer_totals;
customer_totals не видит paid_orders, потому что paid_orders объявлен позже.
Правильно:
WITH
paid_orders AS (
SELECT *
FROM orders
WHERE status = 'paid'
),
customer_totals AS (
SELECT
customer_id,
SUM(amount) AS total_amount
FROM paid_orders
GROUP BY customer_id
)
SELECT *
FROM customer_totals;
Сделать циклическую ссылку
Так нельзя:
WITH
a AS (
SELECT *
FROM b
),
b AS (
SELECT *
FROM a
)
SELECT *
FROM a;
Обычные CTE не могут ссылаться друг на друга по кругу.
Для самоссылки нужен рекурсивный CTE через WITH RECURSIVE, и даже там должна быть понятная логика завершения.
Забыть RECURSIVE
Неправильно:
WITH org_chart AS (
SELECT
id,
name,
manager_id
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT
e.id,
e.name,
e.manager_id
FROM employees AS e
JOIN org_chart AS oc
ON e.manager_id = oc.id
)
SELECT *
FROM org_chart;
CTE org_chart ссылается сам на себя, значит нужно WITH RECURSIVE.
Правильно:
WITH RECURSIVE org_chart AS (
SELECT
id,
name,
manager_id
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT
e.id,
e.name,
e.manager_id
FROM employees AS e
JOIN org_chart AS oc
ON e.manager_id = oc.id
)
SELECT *
FROM org_chart;
Писать слишком много CTE без смысла
CTE должны помогать читать запрос, а не превращать его в длинную лестницу из очевидных шагов.
Плохо, когда каждый CTE просто переименовывает предыдущий:
WITH
step_1 AS (
SELECT *
FROM orders
),
step_2 AS (
SELECT *
FROM step_1
),
step_3 AS (
SELECT *
FROM step_2
)
SELECT *
FROM step_3;
Такой запрос длиннее, но не понятнее.
Хороший CTE должен добавлять смысл: фильтр, расчёт, агрегацию, ранжирование, подготовку данных.
Давать CTE слишком общие имена
Плохо:
WITH
data AS (...),
result AS (...),
final AS (...)
SELECT *
FROM final;
Лучше:
WITH
paid_orders AS (...),
revenue_by_customer AS (...),
top_customers AS (...)
SELECT *
FROM top_customers;
Имя CTE — это маленькая документация внутри запроса.
Как писать хорошие цепочки CTE
Есть простая формула.
Каждый CTE должен отвечать на один понятный вопрос.
Например:
WITH
paid_orders AS (...),
orders_with_items AS (...),
revenue_by_product AS (...),
ranked_products AS (...),
top_products AS (...)
SELECT ...
FROM top_products;
По именам уже видно, что происходит:
- Берём оплаченные заказы.
- Соединяем их с товарами.
- Считаем выручку по товарам.
- Ранжируем товары.
- Оставляем топ.
Такой запрос легко читать, обсуждать с коллегой, проверять и дорабатывать.
Главное про несколько CTE
Несколько CTE позволяют разбить сложный SQL-запрос на цепочку понятных шагов.
Базовый синтаксис:
WITH
first_cte AS (
SELECT ...
),
second_cte AS (
SELECT ...
FROM first_cte
),
third_cte AS (
SELECT ...
FROM second_cte
)
SELECT ...
FROM third_cte;
WITH пишется один раз. CTE перечисляются через запятую.
Каждый следующий CTE может использовать предыдущие. Поэтому порядок объявления важен.
Один и тот же CTE можно использовать несколько раз в финальном запросе, например для сравнения текущего месяца с предыдущим.
Рекурсивные CTE пишутся через WITH RECURSIVE и нужны для деревьев, иерархий и графов. В них есть базовый случай и рекурсивный шаг.
CTE полезны не только с SELECT, но и с INSERT, UPDATE, DELETE, когда нужно сначала аккуратно подготовить набор строк.
Главная идея простая: несколько CTE превращают большой SQL-запрос из запутанной формулы в понятный маршрут. Сначала делаем один шаг, потом второй, потом третий — и в конце получаем результат, который можно читать, проверять и поддерживать без боли.
CTE — это временный именованный результат внутри одного SQL-запроса. Его создают через
WITH ... AS, а потом используют как обычную таблицу.Один CTE уже делает запрос понятнее. Но настоящая сила начинается тогда, когда CTE несколько: один подготовил данные, второй их отфильтровал, третий посчитал агрегаты, четвёртый собрал финальный отчёт.
Получается не огромный клубок из вложенных подзапросов, а аккуратная цепочка шагов. Почти как в обычном коде:
В SQL это можно красиво записать через несколько CTE.
Зачем нужно несколько CTE
Реальные аналитические запросы редко помещаются в один простой
SELECT.Обычно задача выглядит так:
Если всё это запихнуть в один большой вложенный запрос, его будет тяжело читать, проверять и чинить.
Несколько CTE позволяют разложить логику на понятные этапы.
Например, не так:
SELECT ... FROM ( SELECT ... FROM ( SELECT ... FROM ... ) AS t1 ) AS t2;А так:
WITH step_1 AS ( SELECT ... ), step_2 AS ( SELECT ... FROM step_1 ), step_3 AS ( SELECT ... FROM step_2 ) SELECT ... FROM step_3;Второй вариант читается как инструкция. У каждого шага есть имя, а имя объясняет смысл.
Базовый синтаксис нескольких CTE
Если CTE несколько,
WITHпишется один раз в самом начале запроса.Дальше CTE перечисляются через запятую.
WITH first_step AS ( SELECT ... FROM source_table ), second_step AS ( SELECT ... FROM first_step ), third_step AS ( SELECT ... FROM second_step ) SELECT ... FROM third_step;Важные правила:
WITHпишется один раз;AS (...);SELECTидёт после всех CTE;То есть порядок важен.
Так можно:
WITH paid_orders AS ( SELECT * FROM orders WHERE status = 'paid' ), revenue_by_customer AS ( SELECT customer_id, SUM(amount) AS total_revenue FROM paid_orders GROUP BY customer_id ) SELECT * FROM revenue_by_customer;А так нельзя:
WITH revenue_by_customer AS ( SELECT customer_id, SUM(amount) AS total_revenue FROM paid_orders GROUP BY customer_id ), paid_orders AS ( SELECT * FROM orders WHERE status = 'paid' ) SELECT * FROM revenue_by_customer;Первый CTE пытается использовать
paid_orders, но тот объявлен ниже. Для SQL это всё равно что обратиться к переменной до её создания.Как читать цепочку CTE
Хорошая цепочка CTE читается сверху вниз.
Например:
WITH paid_orders AS ( SELECT * FROM orders WHERE status = 'paid' ), customer_totals AS ( SELECT customer_id, COUNT(*) AS orders_count, SUM(amount) AS total_amount FROM paid_orders GROUP BY customer_id ) SELECT customer_id, orders_count, total_amount FROM customer_totals ORDER BY total_amount DESC;Читать можно так:
paid_orders— берём только оплаченные заказы.customer_totals— считаем по каждому клиенту количество заказов и сумму.SELECT— выводим результат и сортируем по сумме.Главная польза CTE в том, что каждый шаг можно понять отдельно. Не нужно держать в голове весь запрос целиком.
Пример: топ-3 заказа каждого клиента
Допустим, есть таблица
orders.И есть таблица
customers.Задача: для каждого клиента взять его 3 самых дорогих заказа, а потом посчитать средний чек среди этих топ-3 заказов.
Запрос через несколько CTE:
WITH ranked_orders AS ( SELECT customer_id, amount, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY amount DESC, id ASC ) AS rn FROM orders ), top_orders AS ( SELECT customer_id, amount FROM ranked_orders WHERE rn <= 3 ), customer_avg AS ( SELECT customer_id, AVG(amount) AS avg_top_amount FROM top_orders GROUP BY customer_id ) SELECT c.id, c.name, ca.avg_top_amount FROM customers AS c JOIN customer_avg AS ca ON ca.customer_id = c.id ORDER BY ca.avg_top_amount DESC;Разберём по шагам.
Шаг 1: пронумеровать заказы клиента
Первый CTE:
WITH ranked_orders AS ( SELECT customer_id, amount, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY amount DESC, id ASC ) AS rn FROM orders ) SELECT * FROM ranked_orders;Он берёт заказы и нумерует их внутри каждого клиента.
PARTITION BY customer_idозначает: для каждого клиента отдельная нумерация.ORDER BY amount DESC, id ASCозначает: сначала самые дорогие заказы. Если суммы одинаковые, порядок стабилизируется поid.Результат может быть таким:
Пока мы ничего не агрегируем. Только добавляем номер заказа в рейтинге клиента.
Шаг 2: оставить только топ-3
Второй CTE использует результат первого.
WITH ranked_orders AS ( SELECT customer_id, amount, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY amount DESC, id ASC ) AS rn FROM orders ), top_orders AS ( SELECT customer_id, amount FROM ranked_orders WHERE rn <= 3 ) SELECT * FROM top_orders;Теперь остаются только заказы с
rn <= 3.Для клиента
10заказ на500исчезнет, потому что он четвёртый по сумме.Этот шаг делает ровно одну вещь: оставляет топовые заказы.
Шаг 3: посчитать среднее по топ-3
Третий CTE берёт
top_ordersи считает среднюю сумму.WITH ranked_orders AS ( SELECT customer_id, amount, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY amount DESC, id ASC ) AS rn FROM orders ), top_orders AS ( SELECT customer_id, amount FROM ranked_orders WHERE rn <= 3 ), customer_avg AS ( SELECT customer_id, AVG(amount) AS avg_top_amount FROM top_orders GROUP BY customer_id ) SELECT * FROM customer_avg;Результат:
Для клиента
10среднее считается по суммам2000,1200,700.Для клиента
20— по суммам900,300,100.Финальный SELECT: добавить имена клиентов
Последний запрос уже не делает сложных расчётов. Он просто соединяет готовый результат с таблицей клиентов.
WITH ranked_orders AS ( SELECT customer_id, amount, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY amount DESC, id ASC ) AS rn FROM orders ), top_orders AS ( SELECT customer_id, amount FROM ranked_orders WHERE rn <= 3 ), customer_avg AS ( SELECT customer_id, AVG(amount) AS avg_top_amount FROM top_orders GROUP BY customer_id ) SELECT c.id, c.name, ca.avg_top_amount FROM customers AS c JOIN customer_avg AS ca ON ca.customer_id = c.id ORDER BY ca.avg_top_amount DESC;Результат:
Запрос получился длинный, но не страшный. Потому что он разбит на понятные части.
Каждый CTE отвечает за один этап:
ranked_orderstop_orderscustomer_avgSELECTВот это и есть хороший стиль работы с CTE.
Почему не написать всё одним подзапросом
Технически тот же запрос можно написать без CTE.
SELECT c.id, c.name, ca.avg_top_amount FROM customers AS c JOIN ( SELECT customer_id, AVG(amount) AS avg_top_amount FROM ( SELECT customer_id, amount FROM ( SELECT customer_id, amount, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY amount DESC, id ASC ) AS rn FROM orders ) AS ranked_orders WHERE rn <= 3 ) AS top_orders GROUP BY customer_id ) AS ca ON ca.customer_id = c.id ORDER BY ca.avg_top_amount DESC;Он делает то же самое. Но читать его намного тяжелее.
Когда подзапросы вложены друг в друга, приходится идти изнутри наружу, постоянно помнить, где какой уровень. Ошибся скобкой — и весь запрос превращается в квест.
CTE делает то же самое более спокойно:
WITH ranked_orders AS (...), top_orders AS (...), customer_avg AS (...) SELECT ... FROM customer_avg;Для учебных задач, аналитики, отчётов и ревью кода CTE обычно понятнее.
Один CTE можно использовать несколько раз
CTE полезен не только как шаг цепочки. Иногда один и тот же промежуточный результат нужен в запросе несколько раз.
Например, хотим посчитать выручку по месяцам и сравнить каждый месяц с предыдущим.
Есть таблица
orders.Сначала удобно собрать месячную выручку в CTE.
WITH monthly_revenue AS ( SELECT DATE_TRUNC('month', created_at) AS month_start, SUM(amount) AS total_amount FROM orders WHERE status = 'paid' GROUP BY DATE_TRUNC('month', created_at) ) SELECT current_month.month_start, current_month.total_amount, previous_month.total_amount AS previous_total_amount, current_month.total_amount - previous_month.total_amount AS amount_diff FROM monthly_revenue AS current_month LEFT JOIN monthly_revenue AS previous_month ON previous_month.month_start = current_month.month_start - INTERVAL '1 month' ORDER BY current_month.month_start;Здесь
monthly_revenueиспользуется дважды:current_month;previous_month.Это обычный
self join: мы соединяем результат сам с собой, чтобы рядом с текущим месяцем увидеть предыдущий.Без CTE пришлось бы писать одну и ту же агрегацию два раза. А так мы один раз дали ей имя
monthly_revenueи спокойно переиспользовали.CTE как способ отладки запроса
Большой плюс CTE — их удобно проверять по одному.
Допустим, финальный запрос не даёт ожидаемый результат. Не нужно сразу разбирать всю конструкцию.
Можно временно заменить финальный
SELECTи посмотреть промежуточный шаг.Например, проверить первый CTE:
WITH ranked_orders AS ( SELECT customer_id, amount, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY amount DESC, id ASC ) AS rn FROM orders ), top_orders AS ( SELECT customer_id, amount FROM ranked_orders WHERE rn <= 3 ), customer_avg AS ( SELECT customer_id, AVG(amount) AS avg_top_amount FROM top_orders GROUP BY customer_id ) SELECT * FROM ranked_orders ORDER BY customer_id, rn;Потом проверить второй:
WITH ranked_orders AS ( SELECT customer_id, amount, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY amount DESC, id ASC ) AS rn FROM orders ), top_orders AS ( SELECT customer_id, amount FROM ranked_orders WHERE rn <= 3 ), customer_avg AS ( SELECT customer_id, AVG(amount) AS avg_top_amount FROM top_orders GROUP BY customer_id ) SELECT * FROM top_orders ORDER BY customer_id, amount DESC;Так проще найти ошибку. Если
ranked_ordersуже неправильный, нет смысла смотреть финальный отчёт. Нужно чинить первый шаг.CTE делает SQL-запрос похожим на конвейер, где каждый участок можно проверить отдельно.
Хорошие имена для CTE
Имя CTE должно отвечать на вопрос: «что лежит внутри?»
Плохие имена:
WITH t1 AS (...), t2 AS (...), t3 AS (...) SELECT * FROM t3;Такие имена ничего не объясняют. Через неделю ты сам забудешь, что такое
t2.Лучше:
WITH paid_orders AS (...), orders_by_customer AS (...), top_customers AS (...) SELECT * FROM top_customers;Хорошие имена не обязаны быть короткими. Они должны быть понятными.
Например:
paid_orders;active_users;monthly_revenue;ranked_orders;top_products;customers_without_orders;orders_with_items;revenue_by_category.Когда CTE названы хорошо, запрос можно понять почти по одним названиям.
Когда несколько CTE особенно полезны
Несколько CTE хорошо подходят для задач, где есть последовательная обработка.
Например: «активные пользователи без заказов за последний месяц».
WITH active_users AS ( SELECT id, email FROM users WHERE is_active = true ), recent_orders AS ( SELECT id, user_id FROM orders WHERE created_at >= DATE '2026-01-01' AND created_at < DATE '2026-02-01' ), users_without_orders AS ( SELECT au.id, au.email FROM active_users AS au LEFT JOIN recent_orders AS ro ON ro.user_id = au.id WHERE ro.id IS NULL ) SELECT id, email FROM users_without_orders ORDER BY id;Что здесь происходит:
active_users— берём активных пользователей.recent_orders— берём заказы за нужный период.users_without_orders— оставляем активных пользователей, у которых нет заказов.SELECT— выводим список.Можно было написать короче, но так логика видна сразу.
CTE и порядок выполнения: важное уточнение
Новички часто думают, что база обязательно выполняет CTE строго как отдельные временные таблицы: сначала первый, потом второй, потом третий.
Логически запрос действительно читается сверху вниз. Но физически оптимизатор базы может выполнить его иначе, если так быстрее.
В PostgreSQL начиная с версии 12 простые CTE часто могут быть встроены в общий план запроса. То есть база не всегда обязана материализовать CTE как отдельную временную таблицу.
Для новичка это значит простую вещь: CTE в первую очередь нужен для читаемости и структуры запроса. А производительность нужно проверять на реальном плане выполнения, если запрос тяжёлый.
MATERIALIZED и NOT MATERIALIZED в PostgreSQL
В PostgreSQL можно подсказать базе, что делать с CTE: материализовать его или попробовать встроить в основной запрос.
Материализовать — значит посчитать CTE отдельно и сохранить промежуточный результат на время выполнения запроса.
WITH paid_orders AS MATERIALIZED ( SELECT * FROM orders WHERE status = 'paid' ) SELECT * FROM paid_orders;Можно и наоборот попросить не материализовать CTE, если это возможно:
WITH paid_orders AS NOT MATERIALIZED ( SELECT * FROM orders WHERE status = 'paid' ) SELECT * FROM paid_orders;Для начинающего это не самая первая тема. В обычных запросах чаще достаточно писать простой CTE без этих слов.
Но важно знать: CTE — это не всегда «железная временная таблица». Современная база может оптимизировать запрос умнее.
Рекурсивные CTE
Обычные CTE идут по цепочке: один использует предыдущий.
Но есть особый вид CTE — рекурсивный. Он может ссылаться сам на себя.
Рекурсивные CTE используют для иерархий:
Допустим, есть таблица сотрудников.
manager_idуказывает на руководителя. У Alice руководителя нет, она на верхнем уровне.Хотим получить оргструктуру с уровнями.
WITH RECURSIVE org_chart 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, oc.level + 1 AS level FROM employees AS e JOIN org_chart AS oc ON e.manager_id = oc.id ) SELECT id, name, manager_id, level FROM org_chart ORDER BY level, id;Результат:
Как работает рекурсивный CTE
Рекурсивный CTE состоит из двух частей.
Первая часть — базовый случай.
SELECT id, name, manager_id, 1 AS level FROM employees WHERE manager_id IS NULLОна выбирает верхний уровень: сотрудников без руководителя.
Вторая часть — рекурсивный шаг.
SELECT e.id, e.name, e.manager_id, oc.level + 1 AS level FROM employees AS e JOIN org_chart AS oc ON e.manager_id = oc.idОна берёт сотрудников, чей руководитель уже найден на предыдущем шаге.
UNION ALLсоединяет базовый случай и все следующие шаги.База повторяет рекурсивный шаг до тех пор, пока появляются новые строки.
Ключевое слово
RECURSIVEобязательно. Без него CTE не сможет ссылаться сам на себя.Защита от бесконечной рекурсии
С рекурсивными CTE нужно быть осторожным. Если в данных есть цикл или условие написано неправильно, запрос может уйти слишком глубоко.
Например, сотрудник
1руководит сотрудником2, а сотрудник2почему-то руководит сотрудником1. Получается круг.Чтобы защититься, часто добавляют ограничение глубины.
WITH RECURSIVE org_chart 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, oc.level + 1 AS level FROM employees AS e JOIN org_chart AS oc ON e.manager_id = oc.id WHERE oc.level < 10 ) SELECT id, name, manager_id, level FROM org_chart ORDER BY level, id;Условие:
WHERE oc.level < 10не даёт рекурсии уйти глубже 10 уровней.
Это не заменяет правильную модель данных, но помогает защититься от неприятных сюрпризов.
Несколько CTE и финальный INSERT
CTE можно использовать не только перед
SELECT.Например, можно подготовить данные через несколько CTE, а потом вставить результат в другую таблицу.
WITH paid_orders AS ( SELECT id, customer_id, amount, created_at FROM orders WHERE status = 'paid' ), monthly_revenue AS ( SELECT DATE_TRUNC('month', created_at) AS month_start, SUM(amount) AS total_amount FROM paid_orders GROUP BY DATE_TRUNC('month', created_at) ) INSERT INTO revenue_report (month_start, total_amount) SELECT month_start, total_amount FROM monthly_revenue;Здесь CTE готовят данные для вставки в таблицу
revenue_report.Это удобно для отчётных таблиц, миграций и backfill-задач.
Несколько CTE и финальный UPDATE
CTE можно использовать и перед
UPDATE.Например, хотим найти клиентов с большой суммой заказов и обновить им сегмент.
WITH customer_totals AS ( SELECT customer_id, SUM(amount) AS total_amount FROM orders WHERE status = 'paid' GROUP BY customer_id ), vip_customers AS ( SELECT customer_id FROM customer_totals WHERE total_amount >= 100000 ) UPDATE customers AS c SET segment = 'vip' FROM vip_customers AS vc WHERE vc.customer_id = c.id;Смысл такой:
customer_totalsсчитает сумму заказов.vip_customersвыбирает клиентов с большой суммой.UPDATEобновляет этих клиентов.Для сложных обновлений CTE часто делает запрос намного понятнее и безопаснее.
Несколько CTE и финальный DELETE
CTE может помочь аккуратно удалить ограниченное количество строк.
Например, в PostgreSQL нет простого синтаксиса
DELETE ... LIMIT, но можно выбрать нужныеidчерез CTE.WITH old_logs AS ( SELECT id FROM logs WHERE created_at < DATE '2024-01-01' ORDER BY id LIMIT 1000 ) DELETE FROM logs WHERE id IN ( SELECT id FROM old_logs );Такой подход полезен, когда нужно удалять данные небольшими порциями, а не сносить огромный объём за один раз.
Когда CTE лучше не использовать
CTE — отличный инструмент, но не нужно оборачивать в него каждый маленький запрос.
Если запрос простой, CTE может только добавить лишний шум.
Например, так можно, но смысла мало:
WITH active_users AS ( SELECT id, email FROM users WHERE is_active = true ) SELECT id, email FROM active_users;Проще написать:
SELECT id, email FROM users WHERE is_active = true;CTE особенно полезен, когда:
INSERT,UPDATEилиDELETE.Если запрос и так читается легко, CTE не обязателен.
Частые ошибки с несколькими CTE
Писать WITH перед каждым CTE
Неправильно:
WITH a AS ( SELECT * FROM orders ) WITH b AS ( SELECT * FROM a ) SELECT * FROM b;Правильно:
WITH a AS ( SELECT * FROM orders ), b AS ( SELECT * FROM a ) SELECT * FROM b;WITHодин, CTE несколько, между ними запятые.Забыть запятую между CTE
Неправильно:
WITH paid_orders AS ( SELECT * FROM orders WHERE status = 'paid' ) customer_totals AS ( SELECT customer_id, SUM(amount) AS total_amount FROM paid_orders GROUP BY customer_id ) SELECT * FROM customer_totals;Между
paid_ordersиcustomer_totalsнужна запятая.Правильно:
WITH paid_orders AS ( SELECT * FROM orders WHERE status = 'paid' ), customer_totals AS ( SELECT customer_id, SUM(amount) AS total_amount FROM paid_orders GROUP BY customer_id ) SELECT * FROM customer_totals;Сослаться на CTE, который объявлен ниже
Неправильно:
WITH customer_totals AS ( SELECT customer_id, SUM(amount) AS total_amount FROM paid_orders GROUP BY customer_id ), paid_orders AS ( SELECT * FROM orders WHERE status = 'paid' ) SELECT * FROM customer_totals;customer_totalsне видитpaid_orders, потому чтоpaid_ordersобъявлен позже.Правильно:
WITH paid_orders AS ( SELECT * FROM orders WHERE status = 'paid' ), customer_totals AS ( SELECT customer_id, SUM(amount) AS total_amount FROM paid_orders GROUP BY customer_id ) SELECT * FROM customer_totals;Сделать циклическую ссылку
Так нельзя:
WITH a AS ( SELECT * FROM b ), b AS ( SELECT * FROM a ) SELECT * FROM a;Обычные CTE не могут ссылаться друг на друга по кругу.
Для самоссылки нужен рекурсивный CTE через
WITH RECURSIVE, и даже там должна быть понятная логика завершения.Забыть RECURSIVE
Неправильно:
WITH org_chart AS ( SELECT id, name, manager_id FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.id, e.name, e.manager_id FROM employees AS e JOIN org_chart AS oc ON e.manager_id = oc.id ) SELECT * FROM org_chart;CTE
org_chartссылается сам на себя, значит нужноWITH RECURSIVE.Правильно:
WITH RECURSIVE org_chart AS ( SELECT id, name, manager_id FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.id, e.name, e.manager_id FROM employees AS e JOIN org_chart AS oc ON e.manager_id = oc.id ) SELECT * FROM org_chart;Писать слишком много CTE без смысла
CTE должны помогать читать запрос, а не превращать его в длинную лестницу из очевидных шагов.
Плохо, когда каждый CTE просто переименовывает предыдущий:
WITH step_1 AS ( SELECT * FROM orders ), step_2 AS ( SELECT * FROM step_1 ), step_3 AS ( SELECT * FROM step_2 ) SELECT * FROM step_3;Такой запрос длиннее, но не понятнее.
Хороший CTE должен добавлять смысл: фильтр, расчёт, агрегацию, ранжирование, подготовку данных.
Давать CTE слишком общие имена
Плохо:
WITH data AS (...), result AS (...), final AS (...) SELECT * FROM final;Лучше:
WITH paid_orders AS (...), revenue_by_customer AS (...), top_customers AS (...) SELECT * FROM top_customers;Имя CTE — это маленькая документация внутри запроса.
Как писать хорошие цепочки CTE
Есть простая формула.
Каждый CTE должен отвечать на один понятный вопрос.
Например:
WITH paid_orders AS (...), orders_with_items AS (...), revenue_by_product AS (...), ranked_products AS (...), top_products AS (...) SELECT ... FROM top_products;По именам уже видно, что происходит:
Такой запрос легко читать, обсуждать с коллегой, проверять и дорабатывать.
Главное про несколько CTE
Несколько CTE позволяют разбить сложный SQL-запрос на цепочку понятных шагов.
Базовый синтаксис:
WITH first_cte AS ( SELECT ... ), second_cte AS ( SELECT ... FROM first_cte ), third_cte AS ( SELECT ... FROM second_cte ) SELECT ... FROM third_cte;WITHпишется один раз. CTE перечисляются через запятую.Каждый следующий CTE может использовать предыдущие. Поэтому порядок объявления важен.
Один и тот же CTE можно использовать несколько раз в финальном запросе, например для сравнения текущего месяца с предыдущим.
Рекурсивные CTE пишутся через
WITH RECURSIVEи нужны для деревьев, иерархий и графов. В них есть базовый случай и рекурсивный шаг.CTE полезны не только с
SELECT, но и сINSERT,UPDATE,DELETE, когда нужно сначала аккуратно подготовить набор строк.Главная идея простая: несколько CTE превращают большой SQL-запрос из запутанной формулы в понятный маршрут. Сначала делаем один шаг, потом второй, потом третий — и в конце получаем результат, который можно читать, проверять и поддерживать без боли.