Всем привет, меня зовут Сергей Прощаев. Я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce, а также преподаю на курсах разработки и архитектуры в OTUS. И в этой статье расскажу, как перестать страдать над многотысячными портянками SQL‑кода и начать писать сложные запросы просто, используя обобщенные табличные выражения (CTE).

Я видел, как разработчики часами медитируют на тройные вложенные JOIN с подзапросами, пытаясь понять, где закралась ошибка. А через неделю они сами не могут объяснить логику своего творения. Знакомая история? Но есть способ получше.

Рис. 1 Иллюстрация: превращение запутанного SQL в элегантную структуру с помощью CTE
Рис. 1 Иллюстрация: превращение запутанного SQL в элегантную структуру с помощью CTE

Почему CTE, а не лапша из подзапросов?

Когда я пришел в FinTech, первое, с чем я столкнулся, — это скрипт миграции данных из монолита в микросервисы. Он состоял из 600 строк сплошного SQL с вложенными SELECTвнутри FROM, внутри JOIN, внутри еще одного SELECT. Распутать этот клубок было невозможно. Тогда я впервые по‑настоящему оценил мощь CTE, переписав этот скрипт в читаемую и поддерживаемую форму.

Давайте посмотрим правде в глаза. SQL — декларативный язык. Мы говорим, что хотим получить, а не как. Но когда вы пишете подзапрос в подзапросе, вы заставляете читателя (и базу данных) строить план выполнения в голове снаружи внутрь или изнутри наружу. Это противоречит линейному ходу чтения.

Что такое CTE простыми словами?

Это временная именованная результирующая таблица, которая существует только в рамках одного запроса. Вы определяете её до основного SELECT с помощью ключевого слова WITH.

Сравните два подхода. Допустим, нужно найти сотрудников, чья зарплата выше средней по отделу, и вывести их боссов.

Без CTE:

SELECT e.name, b.name AS boss_name
FROM employees e
JOIN employees b ON e.boss_id = b.id
WHERE e.salary > (
    SELECT AVG(salary)
    FROM employees e2
    WHERE e2.department_id = e.department_id
);

С CTE:

WITH avg_by_dept AS (
    SELECT department_id, AVG(salary) as avg_sal
    FROM employees
    GROUP BY department_id
)
SELECT e.name, b.name as boss_name
FROM employees e
JOIN employees b ON e.boss_id = b.id
JOIN avg_by_dept a ON e.department_id = a.department_id
WHERE e.salary > a.avg_sal;

В первом случае подзапрос вычисляется заново для каждой строки (теоретически), а логика «средняя по отделу» запрятана в WHERE. Во втором — логика вычисления среднего вынесена наверх, поименована, и дальше мы просто используем готовый результат как обычную таблицу. Читаем сверху вниз: сначала готовим средние, потом джойним. Это естественно.

Разбиваем слона на стейки: конвейер из CTE

Настоящая магия CTE раскрывается, когда вы начинаете выстраивать конвейер обработки данных. Вместо одного монструозного запроса вы делаете несколько последовательных шагов. И не слушайте тех, кто говорит, что это всегда медленнее. В PostgreSQL оптимизатор часто разворачивает CTE как обычный подзапрос, а если нужно — вы можете применить MATERIALIZED, чтобы явно зафиксировать результат шага и избежать повторных вычислений.

Вот реальный пример из e‑commerce. Задача: найти активных пользователей, сделавших за последние 30 дней заказов на сумму более 1000 рублей, и отнести их к сегменту лояльности на основе их истории за год.

Без CTE это был бы нечитаемый монстр с датами и суммами, размазанными по коду. С CTE мы разбиваем этот бизнес‑процесс на атомарные смысловые этапы:

WITH
-- Шаг 1: Активные пользователи за 30 дней
active_users AS (
    SELECT DISTINCT user_id
    FROM orders
    WHERE order_date >= CURRENT_DATE - INTERVAL '30 days'
),
-- Шаг 2: Их выручка за этот период
recent_revenue AS (
    SELECT o.user_id, SUM(o.amount) as total_amount
    FROM orders o
    JOIN active_users u ON o.user_id = u.user_id
    WHERE o.order_date >= CURRENT_DATE - INTERVAL '30 days'
    GROUP BY o.user_id
    HAVING SUM(o.amount) > 1000
),
-- Шаг 3: Годовая история для этих пользователей
yearly_history AS (
    SELECT o.user_id, COUNT(*) as orders_count,
           MAX(o.order_date) as last_order
    FROM orders o
    JOIN recent_revenue r ON o.user_id = r.user_id
    WHERE o.order_date >= CURRENT_DATE - INTERVAL '1 year'
    GROUP BY o.user_id
)

-- Финал: Формируем аналитическую витрину
SELECT u.name, r.total_amount, h.orders_count,
    CASE
        WHEN h.orders_count > 20 THEN 'VIP'
        ELSE 'Regular'
    END as segment
FROM users u
JOIN recent_revenue r ON u.id = r.user_id
JOIN yearly_history h ON u.id = h.user_id;

Согласитесь, каждая часть запроса здесь — отдельная бизнес‑логика. Разработчик читает этот код как повествование. А главное, тестировать можно поэтапно: выполнили SELECT * FROM active_users — проверили, те ли люди попали. Затем recent_revenue — убедились в суммах. Такой подход сокращает время отладки в разы.

CTE не только для SELECT: как UPDATE и DELETE меняют правила игры

Долгое время я думал, что CTE — исключительно инструмент для выборок. Пока не столкнулся с задачей удаления дубликатов в витрине данных. Нужно было оставить только последнюю запись для каждого пользователя, а старые удалить. Раньше это была двухшаговая операция: сначала выборка ID на удаление во временную таблицу, потом DELETE. CTE позволяет сделать это в одной атомарной транзакции:

WITH ranked_events AS (
    SELECT id,
           ROW_NUMBER() OVER (PARTITION BY user_id
                              ORDER BY created_at DESC)
           as rn
    FROM user_events
)
DELETE FROM user_events
WHERE id IN (SELECT id FROM ranked_events WHERE rn > 1);

Или пример с UPDATE. Допустим, нужно проставить пользователям флаг премиум‑статуса на основе суммы их покупок за прошлый месяц:

WITH premium_candidates AS (
    SELECT user_id
    FROM orders
    WHERE order_date >= DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '1 month'
    GROUP BY user_id
    HAVING SUM(amount) > 50000
)
UPDATE users
SET status = 'PREMIUM'
WHERE id IN (SELECT user_id FROM premium_candidates);

Такие конструкции делают код лаконичнее и безопаснее. Нет риска, что между отдельными командами кто‑то изменит данные. Всё работает как единое целое.

Рекурсивные CTE: ваш лифт по дереву иерархии

На одном из проектов в FinTech мы строили витрину организационной структуры холдинга. В таблице departments было поле parent_id. Нужно было развернуть все дерево подчиненности: от гендиректора до линейного персонала. Здесь в дело вступают рекурсивные CTE.

Механика проста: UNION ALL объединяет стартовую точку (корень дерева) с результатом рекурсивного вызова, который ссылается на сам CTE.

WITH RECURSIVE org_tree AS (
    -- База: Топ-менеджмент
    SELECT id, name, parent_id, 1 AS level
    FROM departments
    WHERE parent_id IS NULL

    UNION ALL

    -- Рекурсия: Ищем детей
    SELECT d.id, d.name, d.parent_id,
           t.level + 1
    FROM departments d
    JOIN org_tree t ON d.parent_id = t.id
)
SELECT * FROM org_tree
ORDER BY level, name;

Я помню, как коллега, увидев это, воскликнул: «И всё? Без PL/pgSQL и циклов?». Да, именно так. База данных сама идет вглубь, пока не упрется в листья дерева.

Рис. 2 Принципиальная схема работы рекурсивного CTE
Рис. 2 Принципиальная схема работы рекурсивного CTE

Взгляните на эту схему (рис. 2): рекурсивный CTE работает не как бесконечный цикл, а как управляемый итеративный процесс. Сначала выполняется базовая часть — она даёт «затравку», исходный набор строк. Дальше срабатывает рекурсивная часть, которая ищет строки, связанные с уже найденными.

Если такие строки появились, они добавляются к общему результату и снова подаются на вход рекурсивной части — и так до тех пор, пока очередной шаг не вернёт пустой набор. Тогда рекурсия останавливается, и мы получаем финальный результат. Именно эту механику и важно понимать: без чёткого «дна» рекурсия либо упадёт по limit, либо зациклится.

Еще один кейс — генерация последовательностей. Нужно было разбить бюджетный период на недели для отчета. Вместо использования таблицы‑календаря мы сгенерировали даты на лету:

WITH RECURSIVE weeks AS (
    SELECT DATE '2026-01-01' AS week_start
    UNION ALL
    SELECT (week_start + INTERVAL '7 days')::DATE
    FROM weeks
    WHERE week_start < DATE '2026-12-31'
)
SELECT week_start,
       LEAST(week_start + INTERVAL '6 days',
             DATE '2026-12-31')::DATE AS week_end
FROM weeks;

Особенности оптимизации: что нужно знать

И вот здесь начинается то, что отличает просто разработчика от специалиста, который думает о производительности. Не все CTE одинаково полезны для оптимизатора.

В PostgreSQL до 11 версии включительно все CTE всегда материализовались (вычислялись и сохранялись во временную память), выступая как барьер для оптимизации (optimization fence). Планировщик не мог протолкнуть условия WHERE внутрь CTE и не использовал индексы таблиц, стоящих внутри выражения. Если вы писали WITH cte AS (...SELECT FROM big_table...) SELECT FROM cte WHERE id = 1, база сначала читала всю big_table целиком, и только потом фильтровала. На миллионах строк это катастрофа.

Начиная с PostgreSQL 12, поведение изменилось. Теперь по умолчанию MATERIALIZED применяется, только если CTE используется в запросе больше одного раза или содержит рекурсию. Во всех остальных случаях планировщик разворачивает CTE «на месте» (inlined).

Что это значит для вас как для аналитика или разработчика?

  1. Не надейтесь на CTE как на хинт для исправления плана запроса (как это было раньше, когда через WITH принудительно фиксировали порядок выполнения).

  2. Если вы используете тяжелый CTE несколько раз, имеет смысл явно указать AS MATERIALIZED, чтобы посчитать его один раз.

  3. И наоборот, если CTE используется один раз, но вы хотите гарантированно избежать повторного разворачивания дорогой логики (например, сложный агрегат), используйте AS MATERIALIZED.

Заключение: Пишите код для людей, а не только для машин

Обобщенные табличные выражения — это в первую очередь инструмент коммуникации. Когда вы пишете код через CTE, вы говорите своей команде: «Смотрите, сначала я вычисляю вот это, потом соединяю с этим, а в конце отдаю финальный результат». Это чистая архитектурная мысль, не замутненная синтаксическим шумом.

Умение писать чистые и эффективные CTE — это маркер сильного разработчика или администратора, который умеет думать категориями бизнес‑логики, а не только синтаксиса JOIN. Это навык, который экономит часы отладки и миллионы нервных клеток при поддержке legacy‑кода.

Если эта тема для вас актуальна и вы хотите глубже разобраться в производительности, архитектуре и внутреннем устройстве PostgreSQL, приглашаю вас на открытый урок на платформе OTUS. Урок пройдет в рамках курса «PostgreSQL для администраторов баз данных и разработчиков».

21 мая в 20:00. «SQL: Обобщенное табличное выражение (CTE) — как писать сложные запросы просто».
Участие бесплатное, во время урока можно будет задать вопросы по теме и уточнить практические нюансы работы с CTE.

Регистрация на открытый урок

? Перед регистрацией можно также пройти бесплатное тестирование, чтобы оценить свой уровень и понять, какие темы PostgreSQL требуют дополнительного внимания.

Комментарии (15)


  1. manyakRus
    15.05.2026 07:55

    временные таблицы всегда лучше чем CTE :-)
    т.к.
    1) им можно сщё сделать ANALYSE + CREATE INDEX
    ANALYSE временных таблиц надо делать всегда, т.к. он часто ускоряет запрос в 100 раз

    2) временные таблицы ещё и понятнее в тексте запроса для программиста

    3) временные таблицы можно "дебажить" - запускать отдельно и проверять что там выбралось


    1. Akina
      15.05.2026 07:55

      Всё так. Но у временной таблицы есть пара минусов. Во-первых, это отдельные запросы. Во-вторых, scope=connection. Современные коннекторы при разрыве умеют по-тихому восстанавливать подключение. Вот только временные таблички-то тю-тю.. и начинаешь думать о хранимых процедурах.


    1. ptr128
      15.05.2026 07:55

      Временные таблицы в PostgreSQL уж никак не лучше CTE, так как их создание требует модификации Information Schema. И это не только распухание таблиц метаданных, но и блокировки.

      А вот нежурналируемые постоянные таблицы и pg_variables - вполне разумная альтернатива CTE в ряде случаев.


  1. Akina
    15.05.2026 07:55

    В PostgreSQL оптимизатор часто разворачивает CTE как обычный подзапрос, а если нужно — вы можете применить MATERIALIZED, чтобы явно зафиксировать результат шага и избежать повторных вычислений.

    Если вы используете какой-то CTE более одного раза, и его результат не является детерминированным (недетерминированные функции, неуникальная сортировка и пр.), явная материализация становится совершенно обязательной.

    То, что "Начиная с PostgreSQL 12, поведение изменилось. Теперь по умолчанию MATERIALIZED применяется, только если CTE используется в запросе больше одного раза или содержит рекурсию. Во всех остальных случаях планировщик разворачивает CTE «на месте» (inlined)." - совершенно не утешает. Да, обычно планировщик определяет наличие статической недетерминированности, но вот недетерминированность вследствие особенностей датасета проходит мимо его внимания. Например, ROW_NUMBER() с сортировкой по неуникальному полю.

    CTE не только для SELECT: как UPDATE и DELETE меняют правила игры

    Совершенно неудачные примеры - тут как раз ничего нового не привносится, CTE использован как синтаксический сахар. Ну если не считать того, что Postgres не использует многотабличные DML.

    Я бы рекомендовал дать пример, где DML-подзапрос оформлен как CTE. В этих случаях действительно правила игры серьёзно меняются - в форме подзапросов такое не реализовать.

    Рекурсивные CTE: ваш лифт по дереву иерархии

    Не только иерархии, но и любые графы. Тот же поиск в ширину, например..

    Еще один кейс — генерация последовательностей.

    Вообще - да. Но именно в Postgres - ну очень сомнительная практика. Есть же generate_series. Другое дело, если шаг генерации динамический - тут без рекурсивного CTE ну никак.

    И наоборот, если CTE используется один раз, но вы хотите гарантированно избежать повторного разворачивания дорогой логики (например, сложный агрегат), используйте AS MATERIALIZED.

    Поясните, что вы имеете в виду под повторным разворачиванием логики.

    ===========

    Но общее впечатление от статьи (на фоне, увы, большинства статей от OTUS, посвящённых SQL) - самое положительное. Плюс однозначно.


    1. Ivan22
      15.05.2026 07:55

      Если вы используете какой-то CTE более одного раза, и его результат не является детерминированным (недетерминированные функции, неуникальная сортировка и пр.), явная материализация становится совершенно обязательной.

      если у вас сортировка в ROW_NUMBER недетерменирована, то вам материализация не поможет, а поможет таки детерминация сортировки


      1. Akina
        15.05.2026 07:55

        Ошибаетесь. Материализация гарантирует, что одна и та же запись получит один и тот же результат вычисления функции, вне зависимости от количества последующих обращений к нумерующему CTE. А вот инлайнинг такой гарантии не даст.

        Детерминация сортировки может быть и не нужна. Например, полученный в CTE номер может быть использован для связывания результатов обработки пронумерованных записей в последующих CTE. И тут абсолютно по барабану, насколько криво записи отнумерованы - главное, что номера уникальны, и во всех производных записях одинаковы для одной и той же исходной. Расширение же критерия сортировки до уникального просто утяжелит и замедлит запрос, не давая ну вообще никакого профита.


    1. Ivan22
      15.05.2026 07:55

      И наоборот, если CTE используется один раз, но вы хотите гарантированно избежать повторного разворачивания дорогой логики (например, сложный агрегат), используйте AS MATERIALIZED

      если CTE используется один раз то никакой MATERIALIZED не нужен, он только место лишнее съест


      1. Akina
        15.05.2026 07:55

        Вы смогли понять, что такое "повторно"? Поделитесь, а..


        1. ptr128
          15.05.2026 07:55

          Я так понял, что под повторно имеется в виду несколько агрегаций с FILTER или оконных функций с разными OVER по полям CTE. В таких случаях иногда действительно бывает смысл в материализации.


      1. ptr128
        15.05.2026 07:55

        Материализация CTE - один из методов оптимизации запросов, позволяющий порой направить планировщик на верный путь.


  1. AndrewTishkin
    15.05.2026 07:55

    Ссылки выдают 404


  1. Akina
    15.05.2026 07:55

    Механика проста: UNION ALL объединяет стартовую точку (корень дерева) с результатом рекурсивного вызова, который ссылается на сам CTE.

    Упс. Пропустил поначалу.

    Не всегда используется UNION ALL. Иногда приходится использовать UNION DISTINCT. И именно в этом случае получается нормальный запрос, потому что запись, добавленная на очередной итерации, является полным дубликатом уже имеющейся и отбрасывается, то есть производится накопление только уникальных записей, а при отсутствии новых уникальных на очередной итерации рекурсия завершается. Тогда как при использовании UNION ALL любая запись приводит к продолжению рекурсии, и брутто-эффект - как правило, бесконечная рекурсия, реже куча ненужных дубликатов. И для получения корректного результата приходится добавлять дополнительные поля, которые для самой решаемой задачи не нужны. Пример такой задачи - получение всех узлов графа из таблицы связности, хранящей множество не связанных между собой графов.

    Хотя UNION DISTINCT, конечно, заметно медленнее.


  1. Ivan22
    15.05.2026 07:55

    первый пример лажовый конечно,

    без CTE он должен выглядеть так:

    SELECT e.name, b.name as boss_name
    FROM employees e
    JOIN employees b ON e.boss_id = b.id
    JOIN 
    (    
    	SELECT department_id, AVG(salary) as avg_sal    
    	FROM employees    
    	GROUP BY department_id
    ) a ON e.department_id = a.department_id
    WHERE e.salary > a.avg_sal;

    и тут тогда подзапрос уже не вычисляется заново для каждой строки  и CTE никакого ускорения не даст.

    А вы какую-то лажу коррелированую в качестве сравнения с CTE взяли, так только джуны пишут


    1. Akina
      15.05.2026 07:55

      Я бы сказал, что с этим запросом всё ещё хуже. Априорно почему-то и автор, и вы за ним, предполагаете, чтоboss_id не может содержать значение, отсутствующее в id (в том числе NULL - у нас же "числиться в отделе" и "работать в отделе" вовсе не одно и то же) - но это ровным счётом ниоткуда не следует. А из-заINNER JOIN такие сотрудники не попадут в выходной набор, но их зряплата будет учтена при расчёте средней - то есть в общем случае задача-то решена неправильно.


      1. Ivan22
        15.05.2026 07:55

        Ну и это тоже, я на этого босса даже не посмотрел сначала. Может это нейросеть ему примеры предалгала, тогда все сходится