Подзапросы и CTE: сложные запросы без боли
Подзапросы и CTE (Common Table Expressions) — это инструменты для построения сложной логики прямо внутри SQL-запроса. Вместо того чтобы загружать данные в приложение, обрабатывать их в цикле и делать новые запросы, ты выражаешь всю логику в одном запросе. CTE с оператором WITH дают возможность разбить сложный запрос на читаемые именованные блоки — как функции, но внутри одного SELECT. Это ключевой навык для написания профессиональных SQL-запросов: аналитика, отчёты, иерархические данные, дедупликация — всё это решается подзапросами и CTE без единой строки кода на стороне приложения.
Почему это важно: Без подзапросов и CTE разработчики часто пишут несколько последовательных запросов в коде: первый запрос получает список ID, второй запрос с этими ID получает связанные данные, третий что-то считает. Это N+1 проблема в disguise — множество round-trips к базе, сериализация/десериализация данных, логика размазана между SQL и кодом. CTE позволяют перенести всю логику в базу данных, где она выполняется эффективно: один round-trip, один план выполнения, данные не покидают БД до финального результата. Особенно критично для отчётных запросов, дашбордов и аналитики, где нужно агрегировать данные из нескольких таблиц по сложным условиям.
Главная идея
Подзапрос — это SELECT внутри другого SELECT. Он может появляться в WHERE (фильтрация по результату другого запроса), в FROM (как временная таблица), в SELECT (скалярное значение для каждой строки). CTE с оператором WITH — это способ дать имя подзапросу и переиспользовать его несколько раз. Синтаксис: WITH имя AS (SELECT ...) SELECT * FROM имя. Несколько CTE: WITH a AS (...), b AS (...) SELECT ... FROM a JOIN b. Рекурсивный CTE: WITH RECURSIVE — специальная форма для обхода иерархий: деревья категорий, org-структуры, графы зависимостей. Ключевое понимание: подзапросы и CTE — это не отдельные запросы, это части одного плана выполнения, который оптимизирует PostgreSQL.
Как это выглядит на практике
- Ты строишь страницу профиля пользователя, где нужно показать его 5 последних заказов с суммой и статусом. Начинаешь с простого подзапроса в WHERE: SELECT * FROM orders WHERE user_id = $1 ORDER BY created_at DESC LIMIT 5.
- Появляется требование: показать только пользователей, которые сделали хотя бы один заказ на сумму больше 5000 руб. Пишешь EXISTS: WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.amount > 5000).
- Нужно найти пользователей без ни одного заказа. Пишешь NOT IN (SELECT user_id FROM orders). Тест падает — в orders есть строки с user_id = NULL из-за гостевых заказов. NOT IN возвращает пустой результат. Переписываешь на NOT EXISTS — работает корректно.
- Задача усложняется: нужен отчёт по топ-клиентам с суммой заказов, количеством и датой последнего заказа. Первая попытка — три отдельных запроса в Ruby с объединением в хэш. Понимаешь что это медленно — переносишь в один CTE.
- Пишешь WITH order_stats AS (SELECT user_id, COUNT(*) AS cnt, SUM(amount) AS total, MAX(created_at) AS last_at FROM orders GROUP BY user_id) SELECT u.*, os.* FROM users u JOIN order_stats os ON u.id = os.user_id ORDER BY os.total DESC LIMIT 20. Три round-trips превращаются в один.
- Запрос становится сложнее: нужно добавить фильтрацию по периоду, исключить заблокированных пользователей, добавить их регион из другой таблицы. Разбиваешь на несколько CTE: filtered_orders, user_regions, order_stats — каждый делает одно дело.
- Аналитик просит воронку: сколько пользователей зарегистрировалось → добавило в корзину → купило за последние 30 дней. Три события из разных таблиц. Пишешь три CTE и джойнишь их по дате. Один запрос, один round-trip.
- Новое требование: категории товаров хранятся в виде дерева (parent_id). Нужно получить все подкатегории для раздела 'Одежда' включая вложенные. Обычный JOIN не работает для произвольной глубины. Узнаёшь о рекурсивном CTE.
- Пишешь WITH RECURSIVE cat_tree AS (SELECT * FROM categories WHERE slug = 'clothing' UNION ALL SELECT c.* FROM categories c JOIN cat_tree ct ON c.parent_id = ct.id WHERE ct.depth < 8) SELECT * FROM cat_tree. Получаешь все уровни дерева одним запросом.
- Производительность: замечаешь что запрос с несколькими CTE работает медленнее ожидаемого. Запускаешь EXPLAIN ANALYZE и видишь что один CTE материализуется (PostgreSQL 11) и не использует индекс. Добавляешь NOT MATERIALIZED — производительность восстанавливается.
- Рефакторинг: коллега показывает запрос с 4 вложенными подзапросами в WHERE — нечитаемый. Вы вместе переписываете в CTE: каждый уровень вложенности становится именованным шагом. Код ревью проходит с первого раза.
- На собеседовании спрашивают разницу между CTE и временной таблицей. Объясняешь: CTE живёт только в рамках одного запроса, не создаёт объект в БД, может быть встроен в план оптимизатором. Временная таблица (CREATE TEMP TABLE) материализуется на диске/памяти и доступна в нескольких запросах сессии.
Примеры кода
Подзапросы: IN, EXISTS, скалярный и в FROM
-- Подзапрос в WHERE с IN: пользователи из РФ с заказами
SELECT id, email, name
FROM users
WHERE country = 'RU'
AND id IN (
SELECT DISTINCT user_id
FROM orders
WHERE created_at >= NOW() - INTERVAL '30 days'
);
-- EXISTS: быстрее IN для больших наборов (short-circuit evaluation)
SELECT u.id, u.email
FROM users u
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.user_id = u.id
AND o.status = 'completed'
AND o.amount > 10000
);
-- NOT EXISTS безопасен с NULL (NOT IN опасен!)
-- Найти пользователей БЕЗ ни одного заказа
SELECT u.id, u.email
FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);
-- Подзапрос в FROM (derived table / встроенная таблица)
SELECT
category_id,
avg_price,
CASE WHEN avg_price > 5000 THEN 'premium' ELSE 'standard' END AS segment
FROM (
SELECT category_id, AVG(price) AS avg_price
FROM products
WHERE is_active = true
GROUP BY category_id
) AS category_stats
ORDER BY avg_price DESC;
IN удобен для небольших списков. EXISTS эффективнее для больших наборов — он останавливается при первом совпадении. NOT IN с NULL в подзапросе вернёт пустой результат из-за трёхзначной логики SQL — всегда используй NOT EXISTS вместо NOT IN. Скалярный подзапрос в SELECT выполняется для каждой строки — заменяй его на JOIN или CTE.
CTE: читаемые многоуровневые запросы
-- Простой CTE: топ-10 клиентов за последние 90 дней
WITH recent_orders AS (
SELECT
user_id,
COUNT(*) AS order_count,
SUM(amount) AS total_spent,
MAX(created_at) AS last_order_at
FROM orders
WHERE created_at >= NOW() - INTERVAL '90 days'
AND status = 'completed'
GROUP BY user_id
),
ranked_customers AS (
SELECT
u.id,
u.email,
u.name,
ro.order_count,
ro.total_spent,
ro.last_order_at,
RANK() OVER (ORDER BY ro.total_spent DESC) AS spending_rank
FROM users u
JOIN recent_orders ro ON u.id = ro.user_id
WHERE u.is_active = true
)
SELECT *
FROM ranked_customers
WHERE spending_rank <= 10
ORDER BY spending_rank;
-- CTE с несколькими шагами: отчёт по конверсии воронки
WITH
visits AS (
SELECT DATE(created_at) AS day, COUNT(*) AS visit_count
FROM page_views
WHERE page = '/product' AND created_at >= NOW() - INTERVAL '7 days'
GROUP BY DATE(created_at)
),
cart_adds AS (
SELECT DATE(created_at) AS day, COUNT(*) AS cart_count
FROM cart_events
WHERE event = 'add' AND created_at >= NOW() - INTERVAL '7 days'
GROUP BY DATE(created_at)
),
purchases AS (
SELECT DATE(created_at) AS day, COUNT(*) AS purchase_count
FROM orders
WHERE created_at >= NOW() - INTERVAL '7 days'
GROUP BY DATE(created_at)
)
SELECT
v.day,
v.visit_count,
ca.cart_count,
p.purchase_count,
ROUND(ca.cart_count::numeric / NULLIF(v.visit_count, 0) * 100, 2) AS visit_to_cart_pct,
ROUND(p.purchase_count::numeric / NULLIF(ca.cart_count, 0) * 100, 2) AS cart_to_purchase_pct
FROM visits v
LEFT JOIN cart_adds ca ON v.day = ca.day
LEFT JOIN purchases p ON v.day = p.day
ORDER BY v.day;
CTE с WITH делают сложный запрос читаемым как последовательность шагов. Каждый CTE — именованный блок с понятным назначением. Несколько CTE через запятую выполняются как единый план. Этот воронки-отчёт на чистом SQL заменяет 3 отдельных запроса плюс обработку в Ruby/Python.
Рекурсивный CTE для иерархий
-- Обход дерева: все потомки категории 'Электроника'
WITH RECURSIVE category_tree AS (
-- Базовый случай: корневая категория
SELECT
id,
name,
parent_id,
slug,
0 AS depth,
name::text AS path
FROM categories
WHERE slug = 'electronics'
UNION ALL
-- Рекурсивная часть: дочерние категории
SELECT
c.id,
c.name,
c.parent_id,
c.slug,
ct.depth + 1,
ct.path || ' > ' || c.name
FROM categories c
JOIN category_tree ct ON c.parent_id = ct.id
WHERE ct.depth < 10 -- защита от бесконечного цикла
)
SELECT id, name, depth, path
FROM category_tree
ORDER BY path;
-- Рекурсивный CTE с подсчётом товаров в ветке
WITH RECURSIVE category_tree AS (
SELECT id, name, parent_id, 0 AS depth
FROM categories WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, c.parent_id, ct.depth + 1
FROM categories c
JOIN category_tree ct ON c.parent_id = ct.id
WHERE ct.depth < 5
)
SELECT
ct.id,
ct.name,
ct.depth,
COUNT(p.id) AS product_count
FROM category_tree ct
LEFT JOIN products p ON p.category_id = ct.id AND p.is_active = true
GROUP BY ct.id, ct.name, ct.depth
ORDER BY ct.depth, ct.name;
Рекурсивный CTE состоит из базового случая (корень дерева) и рекурсивной части (каждый следующий уровень), соединённых UNION ALL. Условие WHERE ct.depth < 10 — защита от бесконечного цикла при цикличных данных. Это заменяет несколько запросов в цикле или сложную логику на стороне приложения.
Что происходит под капотом
- Подзапрос в WHERE с IN: PostgreSQL строит HashSet из результата подзапроса, затем проверяет каждую строку внешнего запроса за O(1). При больших наборах (100k+ строк) лучше JOIN или EXISTS.
- EXISTS использует semi-join стратегию: для каждой строки внешнего запроса выполняет подзапрос и останавливается при первом совпадении (short-circuit). Это быстрее IN когда совпадений много в начале набора.
- NOT IN с NULL: SQL использует трёхзначную логику (TRUE/FALSE/UNKNOWN). Если подзапрос содержит NULL, то NOT IN всегда возвращает UNKNOWN — пустой результат. NOT EXISTS корректно обрабатывает NULL через отсутствие строк.
- Коррелированный подзапрос (ссылается на столбцы внешнего запроса) выполняется для каждой строки внешнего запроса. При 10k строк внешнего — 10k выполнений подзапроса. PostgreSQL часто оптимизирует их в JOIN автоматически.
- CTE в PostgreSQL до версии 12 всегда материализовался — выполнялся отдельно и результат сохранялся в памяти. С версии 12+ оптимизатор может встроить (inline) CTE в основной план если это выгодно.
- Явное управление материализацией: WITH cte AS MATERIALIZED (...) — принудительная материализация (защита от многократного выполнения при нескольких ссылках на CTE). WITH cte AS NOT MATERIALIZED (...) — принудительный inline.
- Рекурсивный CTE использует структуру данных WorkTable: базовый случай заполняет таблицу, рекурсивная часть читает из WorkTable и добавляет новые строки пока не останется новых строк или не сработает защитное условие.
- EXPLAIN ANALYZE показывает CTE Scan для материализованных CTE как отдельный узел в дереве плана. Не-материализованный CTE встраивается в план и его узел не виден отдельно — это хорошо для производительности.
- Window functions и CTE: оконные функции (RANK, ROW_NUMBER) нельзя использовать в WHERE, только в SELECT. Стандартный паттерн: WITH ranked AS (SELECT *, RANK() OVER (...) AS r FROM t) SELECT * FROM ranked WHERE r <= 10.
- Derived table (подзапрос в FROM) и CTE с одной ссылкой генерируют идентичный план выполнения в большинстве случаев начиная с PostgreSQL 12. Выбирай тот вариант, который читаемее.
Типичные ошибки и заблуждения
- Ошибка: IN и EXISTS всегда дают одинаковый результат. NOT IN с NULL в подзапросе возвращает пустой результат из-за трёхзначной логики. Всегда проверяй может ли подзапрос вернуть NULL, и используй NOT EXISTS как безопасную альтернативу.
- Ошибка: CTE всегда быстрее подзапроса потому что 'кешируется'. Это зависит от версии PostgreSQL и конфигурации. CTE может материализоваться (медленнее) или инлайниться (как подзапрос). Измеряй с EXPLAIN ANALYZE.
- Ошибка: подзапрос в FROM всегда медленнее явного JOIN. Планировщик часто оптимизирует derived table и JOIN одинаково — генерируется один и тот же план выполнения. Предпочитай тот вариант, который читаемее.
- Ошибка: рекурсивный CTE безопасен по умолчанию. При цикличных данных (A → B → A) рекурсивный CTE уйдёт в бесконечный цикл. Всегда добавляй условие глубины (WHERE depth < N) или проверку на посещённые узлы.
- Ошибка: скалярный подзапрос в SELECT удобен и быстр. Он выполняется для каждой строки результата — при 10k строк это 10k выполнений. Используй LEFT JOIN или CTE + JOIN вместо скалярного подзапроса в SELECT.
- Ошибка: CTE на который есть несколько ссылок выполняется один раз. Не-материализованный CTE может выполняться несколько раз при нескольких ссылках. Используй явный MATERIALIZED если нужно гарантировать однократное выполнение.
- Ошибка: подзапросы делают код модульным как функции. CTE — да, они именованные и переиспользуемые в рамках запроса. Вложенные подзапросы в WHERE — нет, они нечитаемы при глубокой вложенности. Предпочитай CTE для сложной логики.
Ключевые выводы
- CTE (WITH) — разбей сложный запрос на именованные читаемые шаги, как переменные в коде.
- EXISTS эффективнее IN для больших наборов; NOT EXISTS безопасен с NULL в отличие от NOT IN.
- Рекурсивный CTE — единственный способ обойти иерархии (деревья, графы) прямо в SQL без нескольких запросов.
- Скалярный подзапрос в SELECT выполняется для каждой строки — заменяй на JOIN или CTE для производительности.
- EXPLAIN ANALYZE покажет материализован ли CTE (CTE Scan) или встроен в план (нет отдельного узла).
Термины урока
Связь с работой backend-разработчика
Золотое правило: если твой код делает первый запрос, обрабатывает результат в цикле и делает второй запрос — это можно и нужно переписать в один CTE. CTE — это SQL-пайплайн: каждый шаг берёт результат предыдущего. Вся логика выполняется в базе данных, которая оптимизирована для работы с данными. Результат — один round-trip вместо N, меньше памяти в приложении, понятный и тестируемый SQL.
Мини-разбор реальной ситуации
Задача: страница аналитики для менеджера — топ-20 клиентов за квартал с суммой заказов, количеством, средним чеком, регионом и статусом (активный/VIP/churned). Первая реализация: 4 запроса в Ruby, merge в хэш, сортировка в памяти — 800ms. Переписано в один запрос: WITH quarterly_orders AS (фильтрация по дате и статусу), order_stats AS (агрегация по user_id), user_segments AS (определение сегмента через CASE), customer_regions AS (JOIN с регионами). Финальный SELECT из user_segments JOIN всем необходимым с ORDER BY и LIMIT 20. Результат: 45ms, код в одном месте, тестируется одним запросом в psql.
Что запомнить
- NOT EXISTS вместо NOT IN — всегда, если подзапрос может содержать NULL.
- CTE = именованные шаги = читаемый SQL. Разбивай сложную логику на маленькие именованные блоки.
- Рекурсия → защита от бесконечного цикла: WHERE depth < N или проверка посещённых узлов.
- Скалярный подзапрос в SELECT = O(n) запросов. Заменяй на LEFT JOIN + COALESCE.
Итог
Подзапросы и CTE — это переход от 'загружаю данные и обрабатываю в коде' к 'формулирую логику в SQL'. CTE делают сложные запросы читаемыми — каждый именованный блок делает одну вещь, как функция. Рекурсивный CTE открывает целый класс задач (иерархии, графы), которые иначе требуют нескольких запросов или рекурсии в коде. Освоив этот инструментарий, ты начинаешь видеть SQL как мощный язык трансформации данных, а не просто способ 'достать строки из таблицы'.