CTE в SQL: как использовать WITH

Проверь себя · 1/3разбор после ответа
В таблице метрик есть dt, platform, dau. Нужно вывести значение dau за предыдущий день для той же платформы, чтобы посчитать дневное изменение. Какое выражение верное?

Зачем аналитику CTE

CTE (Common Table Expression) — способ разбить сложный запрос на понятные шаги. Если ваши подзапросы вложены в три уровня — вам нужен WITH.

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

На собеседовании по SQL CTE встречается постоянно. Интервьюеры ожидают, что кандидат уровня middle и выше будет использовать WITH для декомпозиции сложных задач. Это не только про правильный ответ — это про читаемость и культуру написания SQL.

Базовый синтаксис

WITH имя_cte AS (
    SELECT ...
    FROM ...
    WHERE ...
)
SELECT *
FROM имя_cte

CTE объявляется ключевым словом 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: когорта, активация, покупка, сборка воронки. Попробуйте написать это вложенными подзапросами — получится нечитаемо.

Прокачай SQL для собеса
500+ задач по SQL: оконные функции, JOIN, CTE — с разбором каждой
Тренировать SQL в Telegram

Рекурсивный 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 можно использовать свободно.