Все материалы
Продуктовая аналитикапрактикумсредний

Подзапросы в SQL: IN, EXISTS, подзапрос в FROM и когда нужен JOIN или CTE

Подзапросы в SQL на реальной учебной базе: IN и EXISTS вместо JOIN, почему NOT IN возвращает ноль строк из-за NULL, подзапрос в FROM для смены зерна, скалярный и коррелированный подзапрос, подзапрос в HAVING и правило выбора между подзапросом, JOIN и CTE.

КейсПрактика30 июля 2026 г.обновлено 2 октября 2026 г.19 мин

Менеджер спрашивает, сколько пользователей из каждого канала хоть раз платили. Аналитик соединяет пользователей с платежами, группирует по каналу и считает строки: organic — 566, referral — 384. Цифры уходят в презентацию. Платили на самом деле 410 и 278 человек: часть подписчиков продлевала тариф, и каждое продление добавляло в результат ещё одну строку. Запрос выполнился без ошибок, и по самим числам ничего не видно. Правильный вопрос здесь — «есть ли этот пользователь среди плативших», и отвечает на него подзапрос, а не JOIN. Ниже разобрано, где подзапрос нужен, где он меняет смысл расчёта и где его лучше заменить. Все примеры посчитаны на учебной базе симулятора SQL-аналитика, их можно повторить в его песочнице и получить те же числа.

Коротко

Подзапрос — это SELECT внутри другого запроса. Смысл у него определяется местом: в WHERE он отвечает «входит ли строка в набор», в FROM создаёт промежуточную таблицу с новым зерном, в SELECT подставляет одно значение. Почти все ошибки с подзапросами — это путаница между этими ролями.

  • IN и EXISTS проверяют наличие и не размножают строки. JOIN соединяет строки и размножает их, если справа несколько совпадений: 912 плательщиков превращаются в 1 251 строку.
  • NOT IN по списку, где есть хотя бы один NULL, возвращает ноль строк. NOT EXISTS так не ломается — для исключения берите его.
  • Подзапрос в FROM нужен, когда метрику надо считать на другом зерне: средний платёж ($24,49) и средняя выручка на плательщика ($33,60) — разные числа из одной таблицы.
  • Скалярный подзапрос обязан вернуть одну строку. Если строк больше, PostgreSQL и современный DuckDB падают с ошибкой, а старые версии DuckDB молча брали случайную.
  • Коррелированный подзапрос ссылается на внешнюю строку и логически выполняется для каждой из них. Его часто можно заменить предварительной агрегацией или оконной функцией.
  • Если подзапрос длиннее пяти строк или используется дважды, вынесите его в CTE. Если из второй таблицы нужны поля, а не факт наличия, нужен JOIN с явным решением о кратности.

Где может стоять подзапрос и что он там делает

Синтаксически подзапрос всегда одинаковый — SELECT в скобках. Но внешний запрос использует его результат по-разному, и от этого зависит, какой результат допустим. Подзапрос в FROM может вернуть сколько угодно строк и колонок. Подзапрос после IN обязан вернуть одну колонку. Подзапрос в SELECT обязан вернуть одну колонку и не больше одной строки.

Вторая ось — зависит ли подзапрос от внешней строки. Некоррелированный подзапрос можно выполнить отдельно, выделив его в редакторе: SELECT count(*) FROM users вернёт 4 613 и без внешнего запроса. Коррелированный ссылается на колонку внешней таблицы, например WHERE p.user_id = u.user_id, и отдельно не выполняется. Это удобный способ проверки: если подзапрос нельзя запустить сам по себе, он коррелированный, и думать о нём нужно как о вычислении для каждой строки.

Роль подзапроса по месту в запросе
МестоЧто возвращаетВопрос, на который отвечаетГлавный риск
WHERE … IN (…)одну колонку, любое число строквходит ли значение в наборNULL в наборе при NOT IN
WHERE EXISTS (…)факт наличия хотя бы одной строкиесть ли связанная строка с условиемзабытое окно по времени
FROM (…) AS tтаблицу со своим зерномчто считать одной строкой дальшезерно не то, которое ждёт внешний запрос
SELECT (…)одно значениекакое число подставить рядомбольше одной строки — ошибка
HAVING … (…)одно значение для сравнениякакие группы выше порогапорог посчитан на другом наборе

Учебная база, на которой посчитаны примеры

Примеры написаны на базе, с которой работает симулятор SQL-аналитика КейсПрактики. Это SaaS-сервис: люди регистрируются, открывают приложение, создают рабочие пространства и отчёты, приглашают коллег и платят за тариф basic ($19), pro ($29) или team ($39). Регистрации идут с 1 июня по 29 августа 2026 года, выгрузка закрыта 30 августа в 13:00. Канал paid_search запущен 1 июля, до этого из него никто не приходил.

Перед любым подзапросом полезно назвать зерно таблиц, с которыми он работает: какая сущность описана одной строкой. У users и payments оно разное, и именно на этой разнице ломаются запросы из введения.

Таблицы учебной базы и их зерно
ТаблицаОдна строкаСтрок
usersзарегистрированный пользователь4 613
eventsсобытие в продукте: app_open, workspace_created, report_created, invite_sent, export_completed35 341
paymentsплатёж: первый или продление1 251
subscriptionsподписка пользователя со статусом active, past_due или churned912
experiment_exposuresпопадание пользователя в вариант эксперимента3 068

Подзапрос в WHERE: IN проверяет, есть ли значение в наборе

Вернёмся к вопросу из введения. Нужна одна строка на канал и число людей, которые хоть раз платили. Вторая таблица здесь служит только списком: её поля в результат не попадают, из неё нужно лишь множество user_id. Это и есть задача для IN.

Подзапрос SELECT user_id FROM payments возвращает 1 251 значение, в котором user_id плативших дважды повторяется. Для IN это неважно: условие проверяет принадлежность к множеству, и пользователь либо проходит фильтр один раз, либо не проходит. Строк во внешнем запросе не становится больше, чем было в users. Всего получается 912 плативших — ровно столько различных user_id в платежах.

Полезно проговорить, что здесь не нужно. DISTINCT внутри IN ничего не меняет в ответе — оптимизатор и так работает с множеством. Внешний count(DISTINCT user_id) тоже не нужен, потому что внешняя таблица не размножалась. Если после IN приходится ставить DISTINCT, чтобы число «сошлось», ошибка где-то ещё.

Плательщики по каналам: одна строка на канал, пользователь учтён один раз
SELECT channel, count(*) AS payers
FROM users
WHERE user_id IN (SELECT user_id FROM payments)
GROUP BY channel
ORDER BY payers DESC;

-- organic 410, referral 278, partner 138, paid_search 86

JOIN на месте IN добавляет по строке за каждое продление

Та же задача через JOIN выглядит почти так же, но отвечает на другой вопрос. После users JOIN payments одна строка — это уже не пользователь, а платёж пользователя. Из 912 плательщиков 290 платили больше одного раза: первый платёж и продления. Поэтому строк 1 251, и count(*) по каналу считает платежи, хотя в заголовке отчёта стоят люди.

Разрыв неравномерный, и это худшее в такой ошибке. У paid_search 103 строки на 86 плательщиков, у organic — 566 на 410. Канал, где продлевают чаще, выглядит в отчёте сильнее, чем есть. Сравнение каналов искажается, а итоговая сумма при этом правдоподобна.

Лечить это через count(DISTINCT u.user_id) можно, и число совпадёт. Но код начинает врать о своём намерении: соединение размножает строки, а DISTINCT склеивает их обратно. Следующий человек добавит в тот же запрос sum(p.amount) — и получит выручку на правильном зерне рядом с числом людей, посчитанным с поправкой. Если из payments нужен только факт оплаты, IN или EXISTS честнее: зерно результата не меняется ни на одном шаге.

Строк после JOIN и реальных плательщиков по каналам

Разница — продления: 290 пользователей платили больше одного раза. Посчитано на учебной базе симулятора SQL-аналитика.

Строк после JOINПлательщиков (IN)

EXISTS: наличие связанной строки с условием на эту строку

EXISTS отвечает на тот же вопрос «есть ли», но устроен иначе: подзапрос связан с внешней строкой и может проверять не только ключ, но и любые условия на пару строк. «Платил ли пользователь в первые 14 дней после регистрации» через IN не записать без лишних конструкций, потому что условие зависит и от даты платежа, и от даты регистрации. Через EXISTS — одна строка условия.

Этот пример показывает главную ловушку EXISTS: забытое окно. Без условия на дату платили 912 человек. С окном в 14 дней — 583. Оба запроса синтаксически верны, но первый смешивает людей, у которых было три месяца на решение, с теми, кто зарегистрировался вчера. Сравнивать каналы по такому числу нельзя: у paid_search, запущенного в июле, в среднем меньше прожитого времени, чем у organic.

Окно в 14 дней требует и второго ограничения — брать только тех, у кого эти 14 дней уже прошли. Платежи в выгрузке есть по 30 августа, поэтому полное окно есть у зарегистрированных по 16 августа включительно. Таких 3 661, и 527 из них заплатили в первые две недели: 14,4%. Наивное «плательщики / все пользователи» дало бы 19,8%, а окно без отсечения незрелых — 12,6%. Правильная цифра ни с одной из них не совпадает.

Три ответа на вопрос «какая доля платит»
Как считалиПлатилиДоляЧто не так
за всё время, все пользователи912 из 4 61319,8%у старых регистраций больше времени
за 14 дней, все пользователи583 из 4 61312,6%у свежих регистраций окно не закрыто
за 14 дней, зрелые когорты527 из 3 66114,4%окно одинаковое у всех
Конверсия в оплату за 14 дней на когортах, у которых окно закрылось
SELECT
  count(*) AS users,
  count(*) FILTER (WHERE EXISTS (
    SELECT 1
    FROM payments p
    WHERE p.user_id = u.user_id
      AND p.paid_at < u.signup_date + 14
  )) AS paid_14d
FROM users u
WHERE u.signup_date <= DATE '2026-08-16';

-- users 3 661, paid_14d 527 → 14,4%

NOT IN и NULL: запрос, который вернул ноль строк

Задача: найти пользователей без активной подписки, чтобы отправить им письмо. Аналитик собирает список активных через LEFT JOIN — от пользователей к подпискам, с условием на статус в ON. Это частый приём, когда рядом нужны и другие поля. Потом исключает этот список через NOT IN. Запрос возвращает ноль строк, хотя без активной подписки 4 098 человек из 4 613.

Причина — в списке. LEFT JOIN сохраняет всех пользователей, и у тех, кому пара не нашлась, s.user_id пустой. В списке 4 613 значений, из них 515 настоящих и 4 098 NULL. Условие x NOT IN (a, b, NULL) раскрывается в x <> a AND x <> b AND x <> NULL. Последнее сравнение даёт не «ложь», а «неизвестно». TRUE AND UNKNOWN — это UNKNOWN, а WHERE пропускает только TRUE. Один NULL в списке делает условие неизвестным для любой строки.

Положительный IN на том же списке работает правильно и возвращает 515: x = a OR x = NULL становится истинным, как только совпало хоть одно значение. Поэтому ошибка и живёт долго — половина запросов с этим списком верная.

NOT EXISTS от NULL не зависит: он проверяет, нашлась ли строка, а не сравнивает значения. Правило простое: для исключения по другой таблице пишите NOT EXISTS. Если по какой-то причине нужен NOT IN, добавьте в подзапрос WHERE user_id IS NOT NULL и оставьте комментарий, зачем.

Одна и та же цель, три результата
-- список «активных», в котором есть NULL
WITH active_list AS (
  SELECT s.user_id
  FROM users u
  LEFT JOIN subscriptions s
    ON s.user_id = u.user_id AND s.status = 'active'
)
SELECT
  (SELECT count(*) FROM users
    WHERE user_id NOT IN (SELECT user_id FROM active_list)) AS not_in,  -- 0
  (SELECT count(*) FROM users
    WHERE user_id IN (SELECT user_id FROM active_list)) AS in_list,      -- 515
  (SELECT count(*) FROM users u
    WHERE NOT EXISTS (
      SELECT 1 FROM subscriptions s
      WHERE s.user_id = u.user_id AND s.status = 'active'
    )) AS not_exists;                                                  -- 4 098
Как заметить это заранее

Перед NOT IN выполните подзапрос отдельно и посчитайте count(*) - count(user_id). Если результат больше нуля, в списке есть NULL, и NOT IN вернёт пустоту. В нашем примере это 4 098.

Подзапрос в FROM: сначала свернуть до нужного зерна

Подзапрос в FROM — это промежуточная таблица, которую внешний запрос читает как обычную. Главная причина его писать — смена зерна. Вопрос «какая средняя выручка на плательщика» нельзя задать таблице payments напрямую: в ней одна строка — платёж, и avg(amount) вернёт средний платёж, $24,49. Чтобы получить среднее на человека, сначала нужна таблица, где одна строка — человек и его сумма. Это и делает подзапрос.

Внутри подзапроса GROUP BY user_id сворачивает 1 251 платёж в 912 строк. Снаружи avg(revenue) усредняет уже по людям: $33,60. Медиана — $29, минимум — $19 (один платёж basic), максимум — $117 (три платежа team). Среднее выше медианы, потому что продления тянут его вверх, и это отдельный вопрос для отчёта: что показывать руководителю.

Третье число из той же таблицы — выручка на пользователя, включая не плативших: $30 639 / 4 613 = $6,64. Здесь подзапрос уже в SELECT, в знаменателе. Три «средних» различаются в пять раз. Ни одно из них не ошибочно, но в отчёте должно быть написано, какое именно показано.

Три средних из одной таблицы платежей
МетрикаОдна строка перед усреднениемЗначение
средний платёжплатёж$24,49
средняя выручка на плательщикаплательщик$33,60
выручка на пользователялюбой пользователь$6,64
Средняя выручка на плательщика через подзапрос в FROM
SELECT
  round(avg(revenue), 2) AS avg_revenue_per_payer,           -- 33.60
  percentile_cont(0.5) WITHIN GROUP (ORDER BY revenue)
                         AS median_revenue,                  -- 29
  count(*)               AS payers                           -- 912
FROM (
  SELECT user_id, sum(amount) AS revenue
  FROM payments
  GROUP BY user_id
) AS per_user;

Скалярный подзапрос: одно число на весь запрос

Скалярный подзапрос возвращает одно значение, и его можно подставить туда, где ожидается число. Типичное применение — знаменатель доли. Чтобы показать, какую долю регистраций даёт каждый канал, нужно общее число пользователей рядом с числом по каналу. После GROUP BY channel общего числа в строке уже нет, и (SELECT count(*) FROM users) возвращает его обратно.

Такой подзапрос не коррелированный: он не ссылается на внешнюю строку, и база вычисляет его один раз, а не для каждого канала. organic даёт 37,9% регистраций, referral — 22,5%, paid_search — 22,0%, partner — 17,6%. Того же можно добиться оконной функцией sum(count(*)) OVER (), но для одного знаменателя подзапрос читается проще.

Второе применение — порог. «Сколько плательщиков заплатили больше среднего» — классическая задача на подзапрос, и в ней снова прячется вопрос о зерне. Если сравнить сумму человека со средним платежом ($24,49), выше окажутся 563 человека. Если со средней выручкой на плательщика ($33,60) — 359. Правильный порог здесь второй: сравниваются люди, значит, и среднее должно быть по людям. Первый вариант превышает средний платёж просто за счёт одного продления.

Доля канала в регистрациях: знаменатель из скалярного подзапроса
SELECT
  channel,
  count(*) AS users,
  round(100.0 * count(*) / (SELECT count(*) FROM users), 1) AS share_pct
FROM users
GROUP BY channel
ORDER BY users DESC;

-- organic 37.9, referral 22.5, paid_search 22.0, partner 17.6

Скалярный подзапрос вернул больше одной строки

Рядом с каналом пользователя хочется показать его тариф: (SELECT plan FROM payments p WHERE p.user_id = u.user_id). У тех, кто платил один раз, это работает. У 290 человек с продлениями подзапрос возвращает две-три строки, и место для одного значения их не вмещает.

PostgreSQL в этом случае падает с ошибкой more than one row returned by a subquery used as an expression. DuckDB в свежих версиях ведёт себя так же и пишет, что прежнее поведение можно вернуть настройкой scalar_subquery_error_on_multiple_rows=false. Прежнее поведение — вернуть случайную строку без предупреждения. Если отчёт давно работает на старой версии, тариф в нём мог быть взят не из того платежа.

Исправление зависит от того, какую строку вы имели в виду. Если нужен последний тариф — отсортируйте и ограничьте: ORDER BY paid_at DESC LIMIT 1. Если любой из одинаковых — агрегат: max(plan). Если нужен тариф первого платежа — условие на payment_type = 'first'. Ошибка полезна: она заставляет решить, какая строка правильная, до того как отчёт уйдёт дальше.

Коррелированный подзапрос и чем его заменить

Подзапрос с условием p.user_id = u.user_id коррелированный: он зависит от текущей строки внешнего запроса. Логически он вычисляется для каждой из 4 613 строк users. (SELECT max(p.paid_at) FROM payments p WHERE p.user_id = u.user_id) вернёт дату последнего платежа у 912 человек и NULL у остальных 3 701.

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

Если нужна не только дата, а вся строка последнего платежа — сумма, тариф, тип, — агрегатом уже не обойтись. Здесь подходят row_number() по пользователю или LEFT JOIN LATERAL с LIMIT 1. Как выбирать между ними и как читать план выполнения, разобрано в отдельной статье про коррелированные подзапросы.

Последний платёж: коррелированный подзапрос
-- коррелированный: вычисляется для каждой строки users
SELECT u.user_id,
  (SELECT max(p.paid_at) FROM payments p WHERE p.user_id = u.user_id) AS last_paid_at
FROM users u
ORDER BY u.user_id
LIMIT 10;

-- без LIMIT — 4 613 строк, дата есть у 912
sqlТот же результат без корреляции: свернуть платежи и присоединить
-- без корреляции: один проход по payments, затем соединение
SELECT u.user_id, l.last_paid_at
FROM users u
LEFT JOIN (
  SELECT user_id, max(paid_at) AS last_paid_at
  FROM payments
  GROUP BY user_id
) AS l ON l.user_id = u.user_id
ORDER BY u.user_id
LIMIT 10;

-- без LIMIT — те же 4 613 строк, дата есть у 912

Подзапрос в HAVING: какие группы выше общего уровня

HAVING фильтрует группы после агрегации, и подзапрос в нём часто задаёт порог, общий для всех групп. Вопрос «в каких каналах доля плательщиков выше средней по продукту» записывается как сравнение доли группы с долей по всей базе. Общая доля — 912 из 4 613, или 19,77%. Выше неё referral (26,81%) и organic (23,47%). partner (16,95%) и paid_search (8,47%) ниже.

Порог внутри HAVING обязан считаться на том же наборе, что и группы. Если группы построены по зрелым когортам, а порог — по всем пользователям, сравнение смещено в пользу каналов со старыми регистрациями. Это та же ошибка окна, что и в разделе про EXISTS, только спрятанная глубже.

Каналы с долей плательщиков выше общей
SELECT
  u.channel,
  round(100.0 * count(DISTINCT p.user_id) / count(DISTINCT u.user_id), 2) AS paying_pct
FROM users u
LEFT JOIN payments p ON p.user_id = u.user_id
GROUP BY u.channel
HAVING count(DISTINCT p.user_id) * 1.0 / count(DISTINCT u.user_id)
     > (SELECT count(DISTINCT user_id) * 1.0 FROM payments)
       / (SELECT count(*) FROM users)
ORDER BY paying_pct DESC;

-- referral 26.81, organic 23.47

Подзапрос, JOIN или CTE: как выбрать

Выбор определяется не вкусом, а тем, что нужно от второй таблицы. Если нужен факт наличия — подзапрос в WHERE. Если поля — JOIN. Если сначала нужна другая таблица с новым зерном — подзапрос в FROM или CTE. Разница между последними двумя — в читаемости, а не в результате.

CTE через WITH — тот же подзапрос в FROM, вынесенный наверх и получивший имя. Его стоит выбирать, когда промежуточная таблица длиннее нескольких строк, когда она используется в запросе дважды или когда запрос будут читать другие люди. Имя per_user объясняет зерно лучше, чем скобки на третьем уровне вложенности. Как разложить длинный расчёт на именованные шаги и проверять каждый, разобрано в статье про CTE.

Что взять под задачу
ЗадачаИнструментПочему
есть ли у пользователя платёжIN или EXISTSстроки не размножаются
есть ли платёж с условием по датамEXISTSусловие зависит от обеих строк
у кого нет активной подпискиNOT EXISTSустойчив к NULL
нужны сумма и тариф платежаJOINполя второй таблицы идут в результат
средняя выручка на человекаподзапрос в FROM или CTEсначала меняется зерно
доля от общего числаскалярный подзапрос или окноодин знаменатель на все строки
последний платёж целикомrow_number() или LATERALнужна строка, а не агрегат
промежуточный набор используется дваждыCTEодно определение вместо двух копий

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

Вложенный запрос читают изнутри наружу, а не сверху вниз. Найдите самый глубокий SELECT, выделите его и выполните отдельно. Если он не выполняется — он коррелированный, и в нём нужно найти ссылку на внешнюю таблицу. Если выполняется — посчитайте строки и назовите зерно одной фразой: «одна строка — пользователь и сумма его платежей».

Затем переходите на уровень выше и задавайте тот же вопрос. На каждом уровне зерно должно меняться осознанно: «платёж → пользователь → канал». Если между уровнями оно не меняется, уровень, скорее всего, лишний. Если меняется в неожиданную сторону — например, после JOIN строк стало больше, чем пользователей, — ошибка найдена.

Два мелких признака помогают при чтении. ORDER BY внутри подзапроса в FROM ничего не гарантирует: порядок строк во внешнем запросе задаётся только внешним ORDER BY, если в подзапросе нет LIMIT. Если сортировка стоит внутри без LIMIT, автор, вероятно, рассчитывал на порядок, которого нет. И псевдоним подзапроса в FROM: до версии 16 PostgreSQL требовал его обязательно, DuckDB — нет. Запрос, написанный в DuckDB, при переносе в старый PostgreSQL может упасть на отсутствии AS t.

  • Выполнить самый глубокий подзапрос отдельно.
  • Посчитать строки и различные ключи на каждом уровне.
  • Назвать зерно каждого уровня одной фразой.
  • Проверить, что после каждого JOIN строк не больше, чем ожидалось.
  • Найти все NOT IN и проверить их списки на NULL.
  • Найти все окна по времени и проверить, закрыты ли они у всех строк.

Частые ошибки с подзапросами

Большинство ошибок с подзапросами не вызывают сообщений об ошибке. Запрос выполняется и возвращает правдоподобное число. Поэтому их ищут не в логе, а сверкой с контрольным числом, посчитанным другим способом.

  • JOIN вместо IN для проверки наличия: 1 251 «плательщик» вместо 912.
  • NOT IN по списку с NULL: ноль строк вместо 4 098.
  • EXISTS без окна по времени: люди с разным временем наблюдения сравниваются как равные.
  • Среднее не того зерна в пороге: 563 «выше среднего» вместо 359.
  • Скалярный подзапрос, который вернул несколько строк: ошибка в PostgreSQL, случайная строка в старом DuckDB.
  • Порог в HAVING посчитан на другом наборе, чем группы.
  • Сортировка внутри подзапроса без LIMIT, на которую рассчитывает внешний запрос.

Практика на учебной базе

Три задачи, которые решаются подзапросом. Попробуйте написать запрос сами, прежде чем смотреть ответ: база та же, что в песочнице симулятора SQL-аналитика, и числа должны совпасть до единицы.

Первая. Сколько пользователей открыли приложение ровно на седьмой день после регистрации? Считайте только тех, у кого седьмой день уже наступил и закончился: регистрации по 22 августа включительно. Подсказка — EXISTS с условием на дату события. Ответ: 881 из 4 109, 21,4%.

Вторая. Среди отправивших хотя бы одно приглашение коллеге какая доля платила? И какая — среди не отправлявших? Подсказка — EXISTS для приглашения и IN для платежа. Ответ: 248 из 1 135 (21,9%) против 664 из 3 478 (19,1%). Разница есть, но выводов о причине из неё не сделать: приглашают и платят, скорее всего, одни и те же активные команды.

Третья. Сколько плательщиков принесли больше средней выручки на плательщика? Подсказка — подзапрос в FROM для суммы на человека и скалярный подзапрос для порога. Ответ: 359. Если получилось 563, порог посчитан по платежам, а не по людям.

Итог

Подзапрос — это способ задать промежуточный вопрос и использовать ответ в основном. Прежде чем писать его, стоит решить три вещи: что нужно от второй таблицы (факт, поля или новое зерно), зависит ли подзапрос от внешней строки и может ли в его результате оказаться NULL или несколько строк там, где ждут одну. После этого выбор между IN, EXISTS, подзапросом в FROM, JOIN и CTE почти всегда очевиден.

Подзапросам посвящена одиннадцатая глава симулятора SQL-аналитика: там те же приёмы отрабатываются на заданиях с автоматической проверкой. Первые главы курса — устройство базы, SELECT и WHERE — открыты бесплатно.

Продолжить чтение
Вся библиотека