Задачи на CTE SQL на собеседовании

Карьерник — квиз-тренажёр в Telegram с 1500+ задач и вопросов для собесов аналитика. SQL, Python, A/B, метрики. Бесплатно.

Зачем спрашивают CTE

На собесах middle-аналитика CTE — обязательный навык. Проверяют:

  • Умеете ли разбивать сложный запрос на понятные шаги
  • Знаете ли цепочки CTE
  • Умеете ли писать рекурсивные CTE
  • Понимаете ли, когда CTE выгоднее подзапроса

Синтаксис (напоминание)

WITH name AS (
    SELECT ...
)
SELECT * FROM name;

Цепочка:

WITH step1 AS (...),
     step2 AS (SELECT ... FROM step1),
     step3 AS (SELECT ... FROM step2)
SELECT * FROM step3;

Задачи с разборами

Задача 1. Пользователи с 3+ заказами и их средний чек

WITH active_users AS (
    SELECT user_id, COUNT(*) AS orders_cnt
    FROM orders
    GROUP BY user_id
    HAVING COUNT(*) >= 3
)
SELECT
    au.user_id,
    au.orders_cnt,
    AVG(o.total) AS avg_check
FROM active_users au
JOIN orders o ON o.user_id = au.user_id
GROUP BY au.user_id, au.orders_cnt;

Идея: сначала фильтр на активных, потом JOIN для среднего чека.


Задача 2. Когорта первой покупки

WITH first_orders AS (
    SELECT
        user_id,
        MIN(created_at) AS first_order_at
    FROM orders
    GROUP BY user_id
)
SELECT
    DATE_TRUNC('month', first_order_at) AS cohort,
    COUNT(*) AS new_buyers
FROM first_orders
GROUP BY 1
ORDER BY 1;

Задача 3. Retention Day-7

WITH first_day AS (
    SELECT user_id, MIN(DATE(event_at)) AS d0
    FROM events
    GROUP BY user_id
),
day_7_active AS (
    SELECT DISTINCT f.user_id
    FROM first_day f
    JOIN events e ON e.user_id = f.user_id
        AND DATE(e.event_at) = f.d0 + INTERVAL '7 days'
)
SELECT
    (SELECT COUNT(*) FROM day_7_active)::FLOAT
    / (SELECT COUNT(*) FROM first_day) AS retention_d7;

Задача 4. Топ-3 товара в каждой категории

WITH ranked AS (
    SELECT
        product_id,
        category,
        sales,
        DENSE_RANK() OVER (PARTITION BY category ORDER BY sales DESC) AS rnk
    FROM products
)
SELECT product_id, category, sales
FROM ranked
WHERE rnk <= 3;

Задача 5. Рекурсивный CTE — иерархия сотрудников

WITH RECURSIVE emp_tree 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, et.level + 1
    FROM employees e
    JOIN emp_tree et ON e.manager_id = et.id
)
SELECT * FROM emp_tree ORDER BY level, name;

Ключевое на собесе: знать структуру рекурсивного CTE — anchor + UNION ALL + recursive part.


Задача 6. Серия активных дней

Найти пользователей, которые заходили 7 дней подряд.

WITH user_days AS (
    SELECT
        user_id,
        DATE(event_at) AS day,
        ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY DATE(event_at)) AS rn
    FROM events
    GROUP BY user_id, DATE(event_at)
),
streaks AS (
    SELECT
        user_id,
        day - rn * INTERVAL '1 day' AS streak_group,
        COUNT(*) AS streak_length
    FROM user_days
    GROUP BY user_id, streak_group
)
SELECT DISTINCT user_id
FROM streaks
WHERE streak_length >= 7;

Задача 7. Когортный retention

WITH cohorts AS (
    SELECT
        user_id,
        DATE_TRUNC('month', MIN(created_at)) AS cohort_month
    FROM users
    GROUP BY user_id
),
activity AS (
    SELECT
        user_id,
        DATE_TRUNC('month', event_at) AS activity_month
    FROM events
    GROUP BY user_id, DATE_TRUNC('month', event_at)
)
SELECT
    c.cohort_month,
    a.activity_month,
    COUNT(DISTINCT a.user_id) AS active_users
FROM cohorts c
JOIN activity a ON a.user_id = c.user_id
GROUP BY 1, 2
ORDER BY 1, 2;

Задача 8. Разные срезы в одном запросе

WITH by_day AS (
    SELECT DATE_TRUNC('day', created_at) AS day, SUM(total) AS rev
    FROM orders GROUP BY 1
),
by_category AS (
    SELECT category, SUM(total) AS rev
    FROM orders GROUP BY 1
),
totals AS (
    SELECT SUM(total) AS total_rev FROM orders
)
SELECT 'daily' AS type, by_day.day::TEXT AS key, by_day.rev FROM by_day
UNION ALL
SELECT 'category', by_category.category, by_category.rev FROM by_category;

Задача 9. Последние 3 заказа каждого пользователя

WITH ranked_orders AS (
    SELECT *,
        ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
    FROM orders
)
SELECT * FROM ranked_orders WHERE rn <= 3;

Задача 10. Пересечение двух таблиц через CTE

WITH emails_a AS (SELECT DISTINCT email FROM users_a),
     emails_b AS (SELECT DISTINCT email FROM users_b)
SELECT email FROM emails_a
INTERSECT
SELECT email FROM emails_b;

Задача 11. Сгенерировать календарь дат (рекурсивный CTE)

WITH RECURSIVE dates AS (
    SELECT DATE '2026-01-01' AS d
    UNION ALL
    SELECT d + INTERVAL '1 day'
    FROM dates
    WHERE d < '2026-12-31'
)
SELECT * FROM dates;

В Postgres чаще используют generate_series, но задача на собесе — написать через рекурсию.


Задача 12. Running total

WITH daily_sales AS (
    SELECT
        DATE_TRUNC('day', created_at) AS day,
        SUM(total) AS daily_rev
    FROM orders
    GROUP BY 1
)
SELECT
    day,
    daily_rev,
    SUM(daily_rev) OVER (ORDER BY day) AS running_total
FROM daily_sales;

CTE vs подзапросы

Критерий CTE Подзапрос
Читаемость высокая средняя при вложенности
Переиспользование да (в одном запросе) нет (копипаста)
Рекурсия да нет
Производительность иногда хуже (materialized) inline

Правило: если один и тот же промежуточный результат используется дважды — CTE. Если только раз — подзапрос.

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

Ошибка 1. RECURSIVE без UNION ALL

-- ошибка: в рекурсивной части должен быть UNION ALL
WITH RECURSIVE t AS (
    SELECT 1 AS n
    SELECT n + 1 FROM t WHERE n < 10  -- пропущен UNION ALL
)

Ошибка 2. Бесконечная рекурсия

-- забыли условие выхода → бесконечный цикл
WITH RECURSIVE t AS (
    SELECT 1 AS n
    UNION ALL
    SELECT n + 1 FROM t  -- нет WHERE → бесконечно
)

Ошибка 3. Обращение к последующему CTE

-- нельзя: step1 ссылается на step2, который определяется позже
WITH step1 AS (SELECT * FROM step2),
     step2 AS (...)

Порядок определения важен.

Ошибка 4. Большие данные в materialized CTE

В старых Postgres (< 12) каждый CTE был materialized (физически отдельная таблица в памяти). На больших данных — медленно. В 12+ работает как inline, если не указать MATERIALIZED.

Связанные темы

FAQ

Что такое CTE?

Common Table Expression — именованный подзапрос, который можно использовать в следующем SELECT. Начинается с WITH.

Когда CTE лучше подзапроса?

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

Рекурсивный CTE — часто спрашивают?

Middle-позиция: могут спросить структуру. Senior: скорее всего дадут задачу на иерархию.

Как CTE влияет на производительность?

В Postgres 12+ и в большинстве СУБД — оптимизируется как подзапрос. В старых системах — как materialized.


Тренируйте SQL — откройте тренажёр с 200+ задачами SQL и 1500+ вопросами для собесов.