CTE (WITH) SQL: шпаргалка для собеседования

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

CTE превращают мутные многоэтажные подзапросы в читаемый пошаговый код. На собеседовании умение переписать кашу из подзапросов в чистый CTE — хороший знак.

CTE (Common Table Expression, они же WITH-выражения) — способ именовать промежуточный результат и использовать его дальше в запросе. Фактически это временный виртуальный датасет, который живёт только внутри одного запроса. Для аналитика CTE — главный инструмент наведения порядка в длинных запросах.

Если готовитесь к собеседованию по SQL, знать CTE обязательно — почти любая задача про воронки, когорты и ранжирование решается именно через WITH.

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

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

Всё, что между скобками после AS — обычный SELECT. Дальше вы обращаетесь к результату по имени, как к таблице.

Цепочка CTE

Можно определить несколько CTE подряд через запятую — каждый следующий видит предыдущие:

WITH
    active_users AS (
        SELECT user_id
        FROM users
        WHERE last_active >= CURRENT_DATE - INTERVAL '7 day'
    ),
    user_orders AS (
        SELECT user_id, COUNT(*) AS orders_cnt, SUM(amount) AS revenue
        FROM orders
        WHERE user_id IN (SELECT user_id FROM active_users)
        GROUP BY user_id
    )
SELECT
    a.user_id,
    COALESCE(o.orders_cnt, 0) AS orders_cnt,
    COALESCE(o.revenue, 0) AS revenue
FROM active_users a
LEFT JOIN user_orders o USING (user_id);

Каждый шаг имеет смысл и проверяется отдельно. Это основная причина, почему CTE лучше вложенных подзапросов: читабельность.

CTE vs подзапрос vs временная таблица

Частый вопрос на собеседовании. Краткое сравнение:

Критерий CTE Подзапрос Temp table
Синтаксис WITH … AS (…) SELECT … FROM (SELECT …) CREATE TEMP TABLE
Область видимости один запрос один запрос сессия
Переиспользование да (в этом запросе) нет да
Материализация зависит от СУБД обычно inline физическая
Индексы нет нет можно
Читаемость ✅ высокая ❌ страдает при вложенности

Что отвечать: CTE и подзапросы — одноразовые конструкции. Временные таблицы живут дольше, их можно индексировать и переиспользовать между запросами. По производительности CTE ≈ подзапрос, но CTE чище для глаз.

Больше таких примеров с разборами — в Telegram-тренажёре. Короткие сессии, прогресс по темам, объяснения после каждого ответа.

Материализация CTE

Важный нюанс, о котором часто спрашивают:

  • PostgreSQL ≤12 — CTE всегда материализуются (результат хранится). Можно принудительно выключить через WITH … AS NOT MATERIALIZED.
  • PostgreSQL 13+ — оптимизатор решает сам, материализовать или нет. По умолчанию не материализует простые CTE.
  • ClickHouse, Snowflake, BigQuery — обычно inline, работают как подзапросы.

Это влияет на производительность в крайних случаях. На собеседовании достаточно знать, что в современных СУБД CTE — это прозрачная обёртка, не тормоза.

Рекурсивные CTE

Самая мощная (и часто проваливаемая на собесе) часть CTE.

WITH RECURSIVE numbers AS (
    SELECT 1 AS n           -- базовый случай
    UNION ALL
    SELECT n + 1             -- рекурсивный шаг
    FROM numbers
    WHERE n < 10             -- условие остановки
)
SELECT n FROM numbers;

Вернёт числа от 1 до 10.

Структура всегда такая же: базовый SELECT + UNION ALL + рекурсивный SELECT со ссылкой на сам CTE.

Практический пример: разворачивание иерархии сотрудников

WITH RECURSIVE tree AS (
    SELECT id, name, manager_id, 1 AS level
    FROM employees
    WHERE manager_id IS NULL   -- корень: CEO

    UNION ALL

    SELECT e.id, e.name, e.manager_id, t.level + 1
    FROM employees e
    JOIN tree t ON e.manager_id = t.id
)
SELECT * FROM tree ORDER BY level;

Возвращает всё дерево подчинённых с уровнем иерархии. На собеседовании просят задачи ровно такого типа.

Заполнение пропущенных дат — ещё одна типовая задача:

WITH RECURSIVE calendar AS (
    SELECT DATE '2026-01-01' AS day
    UNION ALL
    SELECT day + INTERVAL '1 day'
    FROM calendar
    WHERE day < DATE '2026-01-31'
)
SELECT c.day, COALESCE(SUM(o.amount), 0) AS revenue
FROM calendar c
LEFT JOIN orders o ON o.created_at::DATE = c.day
GROUP BY c.day
ORDER BY c.day;

Генерируем календарь и LEFT JOIN-им заказы — получаем строки даже для дней без заказов.

CTE с оконными функциями

Стандартный паттерн: сначала CTE считает агрегаты и ранжирование, потом основной SELECT фильтрует.

WITH ranked AS (
    SELECT
        user_id,
        order_id,
        amount,
        ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS order_num
    FROM orders
)
SELECT *
FROM ranked
WHERE order_num = 1;

«Найдите первый заказ каждого пользователя» — классическая задача. Без CTE придётся дублировать ROW_NUMBER в WHERE, что невозможно (агрегаты и оконные функции в WHERE запрещены).

Модифицирующие CTE

В PostgreSQL (и ряде других СУБД) CTE могут содержать не только SELECT, но и INSERT/UPDATE/DELETE:

WITH deleted AS (
    DELETE FROM logs
    WHERE created_at < CURRENT_DATE - INTERVAL '90 day'
    RETURNING *
)
INSERT INTO logs_archive
SELECT * FROM deleted;

Одним запросом переносим старые логи в архив. Спрашивают реже, но на senior-позициях иногда всплывает.

Частые ошибки в CTE

1. Ссылка на CTE из другого CTE, объявленного позже.

-- ❌ Не сработает: cte_b объявлен после cte_a, но используется в cte_a
WITH cte_a AS (SELECT * FROM cte_b),
     cte_b AS (SELECT 1)
SELECT * FROM cte_a;

Порядок имеет значение — CTE видят только те, что определены раньше. Исключение — рекурсивные CTE, которые ссылаются сами на себя.

2. Забытый RECURSIVE. Рекурсивный CTE без ключевого слова RECURSIVE выдаст ошибку.

3. Бесконечная рекурсия. Забыли условие остановки — запрос будет работать пока не упрётся в лимит (в PostgreSQL по умолчанию он высокий).

4. Переиспользование в разных запросах. CTE живёт один запрос. Если нужно дальше — сохраняйте во временную таблицу.

Если готовишься к собесу — бот @kariernik_bot закрывает 80% технических вопросов. SQL, Python, A/B, продуктовые метрики — всё в одном месте.

10 задач на CTE с собеседований

Задача 1. Первый заказ каждого пользователя

WITH first_orders AS (
    SELECT user_id, order_id, created_at,
        ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS rn
    FROM orders
)
SELECT user_id, order_id, created_at
FROM first_orders
WHERE rn = 1;

Задача 2. Пользователи, сделавшие ≥3 заказа за месяц

WITH monthly_orders AS (
    SELECT user_id, DATE_TRUNC('month', created_at) AS month, COUNT(*) AS cnt
    FROM orders
    GROUP BY user_id, DATE_TRUNC('month', created_at)
)
SELECT DISTINCT user_id
FROM monthly_orders
WHERE cnt >= 3;

Задача 3. Доля каждого города в общей выручке

WITH city_revenue AS (
    SELECT city, SUM(amount) AS rev FROM orders GROUP BY city
),
total AS (
    SELECT SUM(rev) AS all_rev FROM city_revenue
)
SELECT city, rev, ROUND(100.0 * rev / all_rev, 2) AS pct
FROM city_revenue, total
ORDER BY rev DESC;

Задача 4. Воронка: view → cart → purchase

WITH events_pivot AS (
    SELECT user_id,
        MAX(CASE WHEN event = 'view' THEN 1 ELSE 0 END) AS viewed,
        MAX(CASE WHEN event = 'cart' THEN 1 ELSE 0 END) AS carted,
        MAX(CASE WHEN event = 'purchase' THEN 1 ELSE 0 END) AS purchased
    FROM events
    GROUP BY user_id
)
SELECT
    SUM(viewed) AS step1,
    SUM(carted) AS step2,
    SUM(purchased) AS step3
FROM events_pivot;

Задача 5. Retention по неделям

WITH cohort AS (
    SELECT user_id, DATE_TRUNC('week', MIN(created_at)) AS cohort_week
    FROM users GROUP BY user_id
),
activity AS (
    SELECT user_id, DATE_TRUNC('week', event_time) AS active_week
    FROM events
)
SELECT
    c.cohort_week,
    a.active_week,
    COUNT(DISTINCT a.user_id) AS active_users
FROM cohort c
JOIN activity a USING (user_id)
GROUP BY c.cohort_week, a.active_week
ORDER BY 1, 2;

Задача 6. Найти сотрудников, зарплата которых выше средней по отделу

WITH dept_avg AS (
    SELECT department_id, AVG(salary) AS avg_sal
    FROM employees
    GROUP BY department_id
)
SELECT e.name, e.salary, e.department_id, d.avg_sal
FROM employees e
JOIN dept_avg d USING (department_id)
WHERE e.salary > d.avg_sal;

Задача 7. Иерархия менеджеров (рекурсивный CTE)

WITH RECURSIVE chain AS (
    SELECT id, name, manager_id, 0 AS level FROM employees WHERE name = 'Иван'
    UNION ALL
    SELECT e.id, e.name, e.manager_id, c.level + 1
    FROM employees e JOIN chain c ON e.id = c.manager_id
)
SELECT * FROM chain;

От сотрудника вверх по цепочке начальников.

Задача 8. Календарь всех дней месяца с выручкой (включая нули)

WITH RECURSIVE cal AS (
    SELECT DATE '2026-04-01' AS day
    UNION ALL
    SELECT day + INTERVAL '1 day' FROM cal WHERE day < DATE '2026-04-30'
)
SELECT cal.day, COALESCE(SUM(o.amount), 0) AS revenue
FROM cal LEFT JOIN orders o ON o.created_at::DATE = cal.day
GROUP BY cal.day ORDER BY cal.day;

Задача 9. Когорты: первая и последняя активность

WITH user_activity AS (
    SELECT user_id,
        MIN(event_time) AS first_seen,
        MAX(event_time) AS last_seen
    FROM events
    GROUP BY user_id
)
SELECT
    DATE_TRUNC('month', first_seen) AS cohort,
    COUNT(*) AS users,
    AVG(EXTRACT(EPOCH FROM (last_seen - first_seen)) / 86400) AS avg_lifetime_days
FROM user_activity
GROUP BY cohort
ORDER BY cohort;

Задача 10. Категории товаров с ростом продаж MoM >20%

WITH monthly AS (
    SELECT category, DATE_TRUNC('month', created_at) AS month, SUM(amount) AS rev
    FROM orders JOIN products USING (product_id)
    GROUP BY category, DATE_TRUNC('month', created_at)
),
with_lag AS (
    SELECT category, month, rev,
        LAG(rev) OVER (PARTITION BY category ORDER BY month) AS prev_rev
    FROM monthly
)
SELECT category, month, rev, prev_rev,
    ROUND(100.0 * (rev - prev_rev) / prev_rev, 1) AS growth_pct
FROM with_lag
WHERE prev_rev IS NOT NULL AND rev > prev_rev * 1.2;

Как тренироваться

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

Тренажёр Карьерник содержит задачи, где без CTE не обойтись: воронки, когорты, ранжирование, работа с иерархиями. Каждая задача с разбором — почему именно CTE, а не подзапрос.

Совет: на собеседовании начинайте сложную задачу словами «я разобью её на CTE». Даже если решение потом будет через подзапрос — сигнал о структурном мышлении уже отправлен.

Читайте также

FAQ

В чём разница между CTE и подзапросом?

По функциональности — они эквивалентны. CTE именуется и может использоваться несколько раз в основном запросе, подзапрос — одноразовая безымянная обёртка. В современных СУБД (PostgreSQL 13+, ClickHouse, Snowflake) оптимизатор обычно превращает CTE в подзапрос под капотом, так что по скорости разницы нет. Главное преимущество CTE — читаемость.

Когда использовать CTE, а когда временную таблицу?

CTE — если логика нужна только в этом запросе. Временная таблица — если результат переиспользуется в нескольких запросах, нужны индексы или данные тяжёлые (млн+ строк). Также временная таблица даёт оптимизатору больше свободы, когда CTE материализован принудительно и тормозит.

Может ли CTE ссылаться сам на себя?

Да — если он рекурсивный (ключевое слово RECURSIVE). Обычные CTE сами на себя ссылаться не могут. Рекурсивный CTE состоит из базового SELECT, UNION ALL и рекурсивного SELECT со ссылкой на CTE. Используется для иерархий, графов, генерации календарей.

Сколько CTE можно использовать в одном запросе?

Технически — десятки, если СУБД позволит. Практически 3–7 CTE — это чистый код. Если их больше 10, обычно часть стоит вынести во view или промежуточную таблицу — запрос становится слишком тяжёлым для поддержки.