SQLучебникдля начинающихCTEWITHподзапросычасть-6

SQL с нуля. Часть 6: Подзапросы и CTE (WITH) — структурируем запросы

2026-06-02 8 мин

Это часть 6 из 10 учебника «SQL с нуля для аналитика». Содержание серии в конце поста. ← Часть 5


TL;DR: Подзапрос — это SELECT внутри другого SELECT. Может быть во FROM (подзапрос-таблица), WHERE (фильтр-условие), SELECT (значение колонки). CTE (WITH name AS (SELECT ...)) — именованный подзапрос, читается сверху вниз. Для сложных запросов CTE предпочтительнее.

В этой части:


Что такое подзапрос?

Подзапрос — SELECT внутри другого SELECT, в скобках.

Простой пример: «юзеры с выручкой > среднего»:

SELECT user_id, SUM(amount) AS total
FROM orders
GROUP BY user_id
HAVING SUM(amount) > (
    SELECT AVG(user_total)
    FROM (
        SELECT user_id, SUM(amount) AS user_total
        FROM orders
        GROUP BY user_id
    ) sub
);

Подзапрос вычисляет среднюю выручку на юзера. Главный запрос фильтрует юзеров выше неё.

Где может быть подзапрос — 3 места?

МестоПримерЧто делает
FROMFROM (SELECT ... ) subПодзапрос как таблица
WHEREWHERE col IN (SELECT ... )Фильтр-условие
SELECTSELECT (SELECT COUNT(*) ...)Значение колонки

Подзапрос в FROM

-- Сначала агрегируем заказы, потом JOIN с юзерами
SELECT u.email, sub.total
FROM users u
LEFT JOIN (
    SELECT user_id, SUM(amount) AS total
    FROM orders
    GROUP BY user_id
) sub ON u.id = sub.user_id;

Подзапрос в WHERE

-- Юзеры, у которых были заказы в 2026
SELECT * FROM users
WHERE id IN (
    SELECT DISTINCT user_id
    FROM orders
    WHERE created_at >= '2026-01-01'
);

Подзапрос в SELECT (correlated)

-- Для каждого юзера — его кол-во заказов
SELECT
    u.email,
    (SELECT COUNT(*) FROM orders WHERE user_id = u.id) AS orders_count
FROM users u;

Correlated subquery считается для каждой строки — медленно на больших данных. Лучше JOIN.

Что такое CTE и зачем он?

CTE = Common Table Expression. Это именованный подзапрос в виде WITH name AS (SELECT ...). Читается сверху вниз, не вложено.

!CTE flow: каждая WITH — отдельный шаг сверху вниз, финальный SELECT собирает результат

Тот же запрос «юзеры с выручкой > среднего» через CTE:

WITH user_totals AS (
    SELECT user_id, SUM(amount) AS total
    FROM orders
    GROUP BY user_id
),
avg_total AS (
    SELECT AVG(total) AS avg_t FROM user_totals
)
SELECT t.user_id, t.total
FROM user_totals t
CROSS JOIN avg_total a
WHERE t.total > a.avg_t;

В 10 раз читаемее чем nested subquery. По шагам сверху вниз.

Типичный случай первого CTE: ad-hoc запрос вырос до 80 строк с тремя уровнями вложенности. Переписали как 4 шага через WITH — стал читаемым, ревью прошло за 5 минут (вместо 30).

Какие 4 причины брать CTE вместо подзапросов?

Минусы CTE:

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

Да:

WITH paid_orders AS (
    SELECT * FROM orders WHERE status = 'paid'
)
SELECT
    (SELECT COUNT(*) FROM paid_orders) AS total_count,
    (SELECT SUM(amount) FROM paid_orders) AS total_amount,
    (SELECT AVG(amount) FROM paid_orders) AS avg_check;

Полезно для dry: не повторять условие WHERE status = 'paid' три раза.

Что такое recursive CTE?

Recursive CTE — CTE, которая ссылается на саму себя. Используется для обхода иерархий (организационная структура, BOM, граф ссылок).

-- Найти всех подчинённых руководителя id=1 (включая через несколько уровней)
WITH RECURSIVE subordinates AS (
    -- Anchor: прямые подчинённые
    SELECT id, manager_id, name
    FROM employees
    WHERE manager_id = 1

    UNION ALL

    -- Recursive: подчинённые подчинённых
    SELECT e.id, e.manager_id, e.name
    FROM employees e
    INNER JOIN subordinates s ON e.manager_id = s.id
)
SELECT * FROM subordinates;

Подробнее — в CTE рекурсивные SQL.

Какие 4 типичные ошибки с подзапросами и CTE?

Типичный случай: WHERE x NOT IN (SELECT y FROM ...) возвращает пусто — потому что в подзапросе попался NULL. Переписали на WHERE NOT EXISTS (SELECT 1 WHERE ...) — заработало.

Частые вопросы про подзапросы и CTE

CTE медленнее подзапроса?

В современных PG (12+) — нет. В MySQL до 8.0 не было CTE вообще. В ClickHouse CTE есть с 23.x.

EXISTS или IN — что быстрее?

EXISTS обычно быстрее на больших таблицах (стопает на первой найденной строке). IN загружает все значения в memory.

Можно ли в CTE INSERT/UPDATE?

В PG — да, через WITH x AS (DELETE FROM ... RETURNING *). Аналитик такое почти не пишет.

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

Сколько угодно. Норма для сложного аналитического запроса — 5-15 CTE.

dbt — это про CTE?

Во многом да. Каждая dbt-модель = один SELECT с многими CTE сверху. См. dbt macros.

Что дальше?

В Части 7 — NULL, типы данных, COALESCE, CAST, CASE WHEN. NULL — главный источник багов в аналитике, разберём все правила.

В Pro — безлимит мок-собесов на AI-интервью + 491 SQL-задача + 612 тестовых заданий + 50+ блог-постов.


Навигация по учебнику

← Часть 5 | Часть 6: Подзапросы и CTE | Часть 7 →

Содержание серии: 1 · 2 · 3 · 4 · 5 · 6 · 7 · 8 · 9 · 10

← Вернуться к оглавлению

См. также CTE рекурсивные запросы SQL для углубления.

Источники

SQL-тренажёр
Тренируйся писать CTE на PostgreSQL 16. 491 задача, первые 5 бесплатно.
Открыть тренажёр →