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

JOIN без потерь и дублей: как соединение меняет зерно отчёта и выручку

Почему после JOIN выручка вырастает в разы: кардинальность связи, агрегация до соединения, выбор между JOIN и EXISTS, anti-join через NOT EXISTS и ловушка NOT IN с NULL.

КейсПрактика27 июля 2026 г.обновлено 2 октября 2026 г.25 мин
Содержание статьи

В отчёте по выручке была 1 251 оплата на сумму 30 639. Потом попросили добавить рядом активность плательщиков, в запрос приехала таблица events — и выручка стала 284 913. Никто не менял фильтры, не трогал период и не добавлял оплаты: строк в результате стало 11 667 вместо 1 251, и SUM(p.amount) честно сложил сумму каждой оплаты столько раз, сколько событий у её владельца. Это и есть главное свойство соединения: оно меняет зерно результата, а любая метрика поверх нового зерна отвечает уже на другой вопрос. Числа — из учебной базы симулятора, запросы статьи выполняются в его песочнице как есть.

Коротко

Соединение само по себе ничего не портит — портит метрика, посчитанная поверх изменившегося зерна. Поэтому весь разговор про JOIN сводится к одному вопросу: что такое одна строка результата после того, как вы присоединили вторую таблицу.

  • Связь один-ко-многим умножает не только строки, но и все суммы из левой таблицы.
  • Коэффициент роста суммы равен не среднему числу совпадений, а средневзвешенному по самой сумме.
  • Если справа нужен один показатель, сверните правую таблицу в CTE до ключа, а потом соединяйте.
  • Когда нужен факт наличия связи, а не её поля, это EXISTS: он не размножает строки.
  • «Кого нет» выражается через NOT EXISTS; LEFT JOIN ... IS NULL требует трёх дополнительных условий.
  • NOT IN с единственным NULL в подзапросе возвращает ноль строк и выглядит как «таких нет».

Зерно результата меняется раньше, чем вы это заметите в числах

Зерно (grain) — это ответ на вопрос «что описывает одна строка». У таблицы payments зерно очевидное: одна оплата. У events — одно событие. После payments join events по user_id зерно становится «оплата × событие её владельца», и payments в этом наборе больше нет: вместо одной строки на оплату лежит от одной до 19, в каждой повторён один и тот же amount. В интернет-магазине так же ведут себя заказы и их позиции — на них ниже разобрана арифметика.

Ошибка редко выглядит как ошибка. Запрос выполняется, колонки на месте, топ каналов правдоподобен, тренд по дням сохраняет форму. Расходится только абсолютный уровень, а его проверяют реже всего: если в дашборде выручка 284 913, а в платёжном реестре 30 639, поиск причины обычно уходит в сторону курса, возвратов и атрибуции, а не в список таблиц запроса.

Привычка, которая закрывает большую часть таких историй, занимает одну строку: прежде чем писать select, назовите словами зерно результата. «Одна строка — одна оплата». «Одна строка — пользователь за день». «Одна строка — позиция заказа». Дальше каждое соединение проверяется на совместимость с этой фразой, и проверка становится механической.

Одна и та же выручка до и после соединения с событиями
select
  (select count(*)    from payments) as payments_rows,       -- 1251
  (select sum(amount) from payments) as revenue,             -- 30639
  count(*)                           as rows_after_join,     -- 11667
  sum(p.amount)                      as revenue_after_join   -- 284913
from payments as p
join events as e on e.user_id = p.user_id;
Одна фраза перед запросом

Напишите в комментарии к запросу, что такое одна строка результата. Если после очередного JOIN фраза перестала быть правдой, метрику в этом наборе считать уже нельзя — нужен промежуточный слой.

Связь один-ко-многим размножает не строки, а деньги

Кардинальность описывает, сколько строк одной таблицы соответствует одной строке другой. Вариантов три, и риск у них разный. Один-к-одному безопасен: число строк не меняется, а соединение работает как расширение таблицы колонками. Один-ко-многим — основной источник завышенных метрик. Многие-ко-многим даёт произведение и ломает отчёт настолько заметно, что его обычно ловят сразу.

Чтобы увидеть механику без больших чисел, хватит пяти условных заказов с позициями: в учебной базе таких таблиц нет, пример собран руками. Сумма по orders — 17 000, это контрольное число. После соединения с позициями строк становится 11, и SUM(o.amount) по ним даёт 52 000. Заказ №4 на 8 600 содержит четыре позиции и попадает в сумму четырежды — один он добавляет 34 400 из 52 000.

Обратите внимание на коэффициент. Среднее число позиций на заказ здесь 11 / 5 = 2,2, а сумма выросла в 52 000 / 17 000 = 3,06 раза. Расхождение не случайно: сумма умножается на средневзвешенное число совпадений, где весом выступает сама сумма. Крупные заказы почти всегда содержат больше позиций, поэтому искажение выручки обычно сильнее искажения количества строк. На учебной базе этого эффекта нет: цена тарифа с числом событий почти не связана, и коэффициенты близки — строк стало в 9,33 раза больше, выручки — в 9,30.

Коэффициент искажения суммы
SUM после JOIN / SUM до JOIN = Σ(amount × совпадений) / Σ(amount)

Это средневзвешенное число совпадений с весом amount. Оно совпадает со средним числом совпадений только если сумма не связана с их количеством — для заказов и позиций это почти никогда не так.

Пять условных заказов: что делает с суммой соединение с позициями
order_idamountпозиций = строк после JOINвклад в SUM(o.amount)доля в 52 000
11 20011 2002%
23 40026 80013%
390019002%
48 600434 40066%
52 90038 70017%
итого17 0001152 000100%

Два соединения подряд перемножаются, а не складываются

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

Продолжим пример. Добавим к тем же пяти заказам события: у №1 их два, у №2 одно, у №3 три, у №4 два, у №5 ни одного. Строк после второго соединения: 1×2 + 2×1 + 1×3 + 4×2 + 3×1 = 18, где у №5 остаётся три исходные строки, потому что left join не выбрасывает заказ без событий. Сумма по этому набору — 2 400 + 6 800 + 2 700 + 68 800 + 8 700 = 89 400, то есть в 5,26 раза больше контрольных 17 000.

Заказ №4 в финальном наборе занимает 8 строк из 18 и приносит 68 800 из 89 400 — больше трёх четвертей «выручки». Любой срез по этому набору получает смещение в пользу заказов с длинной историей: отчёт по категориям переоценит те, что встречаются в крупных многопозиционных заказах, отчёт по каналам — тот канал, где таких заказов больше. Форма распределения поедет, причём правдоподобно.

На учебной базе цепочка та же, только начинается с пользователя: к users присоединяются оплаты, потом события. Пользователь с тремя оплатами и 19 событиями занимает в таком наборе 57 строк.

Число строк на каждом шаге соединения, учебная база

Пользователей 4 613. После присоединения оплат строк 4 952, после событий — 38 917. Уникальных user_id по-прежнему 4 613: выросло не количество пользователей, а количество их копий. SUM(p.amount) по этому набору — 284 913 вместо 30 639.

Строк в результате
Пять условных заказов: число строк и сумма на каждом шаге
-- учебная вставка: пять заказов, их позиции и события (в учебной базе таких таблиц нет)
with orders(order_id, amount) as (
  values (1, 1200), (2, 3400), (3, 900), (4, 8600), (5, 2900)
),
order_items(order_id) as (     -- одна строка — одна позиция заказа
  values (1), (2), (2), (3), (4), (4), (4), (4), (5), (5), (5)
),
order_events(order_id) as (    -- одна строка — одно событие, у заказа 5 событий нет
  values (1), (1), (2), (3), (3), (3), (4), (4)
)
select 1 as step, 'orders' as joined, count(*) as rows_count, sum(o.amount) as revenue
from orders as o
union all
select 2, 'orders + order_items', count(*), sum(o.amount)
from orders as o
join order_items as i on i.order_id = o.order_id
union all
select 3, 'orders + order_items + order_events', count(*), sum(o.amount)
from orders as o
join order_items as i on i.order_id = o.order_id
left join order_events as e on e.order_id = o.order_id
order by step;
sqlТе же шаги на учебной базе: пользователи, оплаты, события
select
  (select count(*) from users)                        as users_rows,      -- 4613
  (select count(*)
   from users as u
   left join payments as p on p.user_id = u.user_id)  as with_payments,   -- 4952
  count(*)                                            as with_events,     -- 38917
  count(distinct u.user_id)                           as users_distinct,  -- 4613
  sum(p.amount)                                       as revenue          -- 284913 вместо 30639
from users as u
left join payments as p on p.user_id = u.user_id
left join events   as e on e.user_id = u.user_id;

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

Угадывать связь по названию таблицы не нужно — она измеряется. Для каждой правой таблицы сравните число строк с числом уникальных значений ключа, по которому собираетесь соединять. Равенство означает один-к-одному и безопасный JOIN. Любая разница означает один-ко-многим, и дальше вопрос только в том, сворачивать правую сторону или менять зерно отчёта. В учебной базе проверку по user_id проходит только subscriptions: 912 строк и 912 ключей. У payments лишних строк 339, у events — 30 728.

Второй запрос полезнее первого: распределение числа совпадений на ключ. Оно сразу показывает и характер связи, и выбросы. В учебной базе выбросов нет: от одного события (73 пользователя) до 22, у семи человек — 19 и больше, средний множитель 7,66. Иначе выглядел бы условный магазин, где у 980 заказов одна-две позиции, а у двадцати по сорок: среднее там ничего не объясняет, и перекос в отчёте создадут именно эти двадцать.

Ту же проверку стоит применять к левой таблице, если ключ соединения не является её первичным ключом. В главном примере статьи так и есть: user_id не уникален и в payments — у 290 плательщиков по две-три оплаты. Связь оплат с событиями поэтому уже многие-ко-многим, и лечат её двумя агрегациями — по одной на каждую сторону. Классика за пределами учебной базы — соединение по email вместо user_id: один адрес на двух аккаунтах удваивает каждый заказ ещё до того, как в запрос приехали позиции.

Что делать с каждым типом связи
СвязьКак выглядит в проверкеРешение
один-к-одномуcount(*) = count(distinct key)соединять напрямую
один-ко-многимcount(*) > count(distinct key)свернуть правую таблицу до ключа
многие-ко-многимобе стороны неуникальнысоединять через явный мост или две агрегации
неполный ключдубли исчезают при добавлении колонки в ключдописать колонку в условие ON
Первая проверка: уникален ли ключ в правой таблице
select 'events' as right_table,
       count(*)                            as rows_total,
       count(distinct user_id)             as keys,
       count(*) - count(distinct user_id)  as extra_rows
from events
union all
select 'payments', count(*), count(distinct user_id), count(*) - count(distinct user_id)
from payments
union all
select 'subscriptions', count(*), count(distinct user_id), count(*) - count(distinct user_id)
from subscriptions
order by right_table;
sqlВторая проверка: как распределены совпадения на один ключ
select events_per_user, count(*) as users
from (
  select user_id, count(*) as events_per_user
  from events
  group by user_id
) as per_user
group by events_per_user
order by events_per_user;

Сумму на заказ считают до соединения, а не после

Отсюда следует главное правило работы с фактами: каждая таблица приводится к зерну отчёта отдельно, и только потом наборы соединяются. Если отчёт отвечает на вопрос про заказы, то и позиции, и события должны прийти в него уже свёрнутыми до order_id — по одной строке на заказ. Тогда связь становится один-к-одному, а JOIN перестаёт влиять на суммы. В учебной базе зерно отчёта — пользователь: до user_id сворачиваются и оплаты, и события.

Технически это один-два CTE с group by и left join по ключу. Практически это ещё и способ разделить ответственность внутри запроса: в каждом CTE видно своё зерно, и при расхождении числа можно проверять по шагам, а не целиком. left join вместо join здесь обязателен: пользователь без оплат или без событий должен остаться в отчёте с нулём, а не исчезнуть из него.

Контроль после сборки тоже простой. Сумма столбца revenue в отчёте обязана совпасть с SUM(amount) по payments — это тождество, а не совпадение. На учебной базе четыре канала дают 14 114 + 2 467 + 4 732 + 9 326 = 30 639, оплат — 1 251, событий — 35 341. Если тождество не выполняется, какое-то из соединений осталось один-ко-многим, и искать нужно в нём, а не в данных.

Обратная сторона правила: свёрнутый слой отвечает не на все вопросы. В таком отчёте нельзя посчитать выручку по тарифам, потому что plan живёт на уровне оплаты, а не пользователя. Это нормальная ситуация, и решается она не возвратом к сырому соединению, а вторым отчётом с зерном «оплата», где amount суммируется прямо по payments, без событий рядом.

Оплаты и события приведены к зерну пользователя до соединения
with payment_totals as (        -- зерно: один пользователь
  select user_id,
         sum(amount) as revenue,
         count(*)    as payments
  from payments
  group by user_id
),
event_totals as (               -- зерно: один пользователь
  select user_id,
         count(*) as events
  from events
  group by user_id
)
select
  u.channel,
  count(*)                      as users,
  count(p.user_id)              as payers,
  coalesce(sum(p.payments), 0)  as payments,
  coalesce(sum(p.revenue), 0)   as revenue,
  coalesce(sum(e.events), 0)    as events
from users as u
left join payment_totals as p on p.user_id = u.user_id
left join event_totals   as e on e.user_id = u.user_id
group by u.channel
order by u.channel;
Два зерна — два отчёта

Метрики уровня пользователя и уровня оплаты не живут в одной таблице результата. Выручку по каналам считайте на свёрнутом слое, выручку по тарифам — на слое оплат, и в каждом случае берите денежную колонку того же уровня.

DISTINCT не лечит размножение, а меняет одну ошибку на другую

Когда строк после соединения стало больше, первым под руку попадает select distinct. Иногда он даже возвращает правильное число строк — и именно это делает его опасным: проблема уходит с экрана, оставаясь в запросе.

Дедупликация идёт по всем выведенным колонкам, а не по ключу оплаты. Пока в select есть event_time или event_name, строки различаются и distinct не убирает ничего, кроме полностью совпавших. А как только вы оставите только колонки уровня оплаты, появится обратная ошибка: две оплаты одного пользователя на одну сумму схлопнутся в одну, и выручка окажется заниженной. На учебной базе select distinct user_id, amount поверх соединения возвращает 912 строк и 22 328 вместо 30 639: продление стоит столько же, сколько первая оплата, и все 339 продлений исчезли.

SUM(distinct amount) не спасает по той же причине, только хуже: он складывает уникальные значения суммы. На той же базе это 87: цен три — 19, 29 и 39, — и каждая учтена один раз. Оба приёма обещают исправить зерно, но работают со значениями, а зерно задаётся ключом — поэтому починить его можно только агрегацией по ключу.

Почему каждая быстрая починка не работает
ПриёмЧто делает на самом делеЧем заканчивается
select distinctубирает полностью совпавшие строкизавышение остаётся или появляется занижение
sum(distinct amount)складывает уникальные значения суммыдве равные оплаты считаются одной
count(distinct payment_id)верно считает оплатыно не помогает с деньгами
group by до JOINприводит правую таблицу к ключуконтрольная сумма сохраняется
Две быстрые починки на учебной базе: обе дают неверную выручку
with joined as (               -- оплата × событие её владельца
  select p.user_id, p.amount
  from payments as p
  join events as e on e.user_id = p.user_id
),
deduplicated as (              -- «починка»: distinct по колонкам уровня оплаты
  select distinct user_id, amount
  from joined
)
select
  (select sum(amount) from payments)        as revenue_true,             -- 30639
  (select sum(amount) from joined)          as revenue_joined,           -- 284913
  (select count(*) from deduplicated)       as rows_select_distinct,     -- 912
  (select sum(amount) from deduplicated)    as revenue_select_distinct,  -- 22328
  (select sum(distinct amount) from joined) as revenue_sum_distinct;     -- 87

LEFT JOIN с условием правой таблицы в WHERE — это INNER JOIN

left join обещает сохранить все строки левой таблицы. Обещание действует до where: фильтр применяется уже к результату соединения, а у несовпавших строк все правые колонки равны NULL. Условие e.event_name = 'export_completed' для такой строки даёт UNKNOWN, строка отбрасывается, и соединение тихо становится внутренним.

Разделение простое и работает всегда: всё, что описывает, какая правая строка считается совпадением, живёт в on; в where остаются условия по левой таблице. Исключение — проверка right.key is null, которая как раз и работает с результатом соединения.

Эту же ошибку легко опознать по симптому. Если число строк после left join с фильтром равно числу строк после join, значит фильтр уже отработал как внутреннее соединение, и пользователи без события из отчёта ушли. На учебной базе так и выходит: 988 строк с фильтром в where — столько же, сколько у join, — и 4 613 с условием в on, из них 3 625 с NULL на месте события.

Одно и то же соединение с условием в WHERE и в ON
with filter_in_where as (   -- LEFT JOIN только на вид: пользователи без выгрузки отфильтрованы
  select u.user_id, e.event_time
  from users as u
  left join events as e on e.user_id = u.user_id
  where e.event_name = 'export_completed'
),
filter_in_on as (          -- условие совпадения уехало в ON: пользователи без события остались с NULL
  select u.user_id, e.event_time
  from users as u
  left join events as e
    on e.user_id = u.user_id
   and e.event_name = 'export_completed'
)
select
  (select count(*) from filter_in_where)                        as rows_filter_in_where,  -- 988
  (select count(*)
   from users as u
   join events as e on e.user_id = u.user_id
   where e.event_name = 'export_completed')                     as rows_inner_join,       -- 988
  (select count(*) from filter_in_on)                           as rows_filter_in_on,     -- 4613
  (select count(*) from filter_in_on where event_time is null)  as users_without_event;   -- 3625

IN сравнивает значения, EXISTS проверяет существование строки

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

IN сравнивает значение с набором значений: u.user_id in (select user_id from payments). EXISTS проверяет, вернул ли коррелированный подзапрос хотя бы одну строку, и может остановиться на первой. Оба варианта дают semi-join: левая строка либо остаётся целиком, либо не остаётся, и размножения не происходит по построению. Именно этим они отличаются от join, после которого пользователь с тремя оплатами превращается в три строки.

Масштаб разницы видно на числах учебной базы: 4 613 пользователей, 1 251 оплата. Наивное соединение даёт 1 251 строку, и count(*) по нему — это количество оплат, а не людей. EXISTS возвращает 912 строк, по одной на пользователя с оплатой. Довесок select distinct поверх соединения вернёт те же 912, но сначала соберёт 1 251 строку и только потом уберёт повторы — результат сойдётся, работа будет сделана лишняя.

Про производительность стоит говорить аккуратно: современный планировщик в PostgreSQL сводит IN (подзапрос) и EXISTS к одному и тому же semi-join, и обещать, что один оператор быстрее, нельзя. Разница между ними лежит в читаемости и в поведении на NULL. IN естественно читается на коротком статическом списке: where plan in ('basic', 'pro', 'team'). EXISTS прямо называет вопрос — «существует ли связанная строка», — и в него удобно дописывать условия по окну и статусу.

Semi-join оставляет пользователя один раз

В учебной базе 4 613 пользователей и 1 251 оплата. JOIN вернёт 1 251 строку, EXISTS — 912: столько пользователей сделали хотя бы одну оплату.

Строк в результате
Три способа задать один вопрос и то, что они возвращают
with via_join as (      -- зерно результата — оплата, а не пользователь
  select u.user_id, u.channel
  from users as u
  join payments as p on p.user_id = u.user_id
),
via_exists as (         -- пользователь остаётся один раз
  select u.user_id, u.channel
  from users as u
  where exists (
    select 1
    from payments as p
    where p.user_id = u.user_id
  )
),
via_in as (             -- тот же ответ через IN: читается как «входит в список»
  select u.user_id, u.channel
  from users as u
  where u.user_id in (select p.user_id from payments as p)
)
select
  (select count(*) from via_join)                  as join_rows,           -- 1251
  (select count(*)
   from (select distinct user_id, channel
         from via_join) as d)                      as join_distinct_rows,  -- 912
  (select count(*) from via_exists)                as exists_rows,         -- 912
  (select count(*) from via_in)                    as in_rows;             -- 912

Как только понадобились колонки справа, это снова JOIN

EXISTS не отдаёт поля подзапроса наружу — select 1 внутри существует именно для того, чтобы это подчеркнуть. Поэтому вопрос «кто покупал» он закрывает, а вопрос «кто покупал и на сколько» — нет.

Граница проходит по тому, что должно оказаться в отчёте. Нужен признак — EXISTS. Нужна сумма, дата первой оплаты, число оплат — нужен слой, свёрнутый до user_id, и обычный left join к нему. Это тот же приём, что и в отчёте по каналам: сначала агрегация до нужного зерна, потом соединение. Запрос ниже возвращает 51 строку — по одной на каждого, кто зарегистрировался 1 июля, — и у шести из них есть оплаты.

Смешанный вариант, который встречается чаще всего и работает хуже всех: join ради одной колонки справа плюс select distinct сверху, чтобы убрать получившиеся дубли. Он даёт правильное число строк, пока выбранная колонка одинакова у всех совпадений, и ломается, как только она различается: на учебной базе с p.plan получится 912 строк, с p.paid_at — 1 251.

Что выбрать под вопрос
ВопросКонструкцияПочему
значение входит в короткий списокIN (список)читается как перечисление
связанная строка существуетEXISTSне меняет зерно
связанной строки нетNOT EXISTSустойчив к NULL и дублям
нужны суммы и даты справаагрегат в CTE + LEFT JOINправая сторона становится 1:1
нужна детализация справаJOIN и новое зерно отчётаметрики пересчитываются под него
Признак оплаты и числа — из слоя, свёрнутого до пользователя
with paid as (                 -- зерно: один пользователь
  select user_id,
         count(*)     as payments,
         sum(amount)  as revenue,
         min(paid_at) as first_paid_at
  from payments
  group by user_id
)
select
  u.user_id,
  u.channel,
  p.user_id is not null    as has_payment,
  coalesce(p.payments, 0)  as payments,
  coalesce(p.revenue, 0)   as revenue,
  p.first_paid_at
from users as u
left join paid as p on p.user_id = u.user_id
where u.signup_date = date '2026-07-01'
order by u.user_id;

NOT EXISTS отвечает на вопрос «кого нет» и не ломается на дублях

Сегмент «не купили» нельзя получить вычитанием одного отчёта из другого глазами: нужна явная отрицательная проверка. Anti-join возвращает строки левой таблицы, для которых справа не нашлось ни одного совпадения, и в SQL это not exists.

Конструкция собирается из четырёх частей, и каждую стоит назвать вслух. Левая база — кто вообще участвует в вопросе (пользователи, зарегистрированные в первую неделю июля). Совпадение — какая правая строка считается покупкой (любая строка payments). Окно — в какой период она должна была появиться (первые 14 дней от регистрации каждого пользователя). Ключ — чем связаны стороны (user_id). Пропустите любую из четырёх, и запрос ответит на чужой вопрос, оставшись синтаксически безупречным.

Устойчивость not exists к дублям полезна на практике: сколько бы покупок ни было у пользователя, подзапрос интересует только факт существования хотя бы одной. Поэтому результат anti-join всегда содержит ровно по одной строке на объект левой базы — и сумма сегментов сходится с базой без дополнительных усилий. На когорте первой недели июля это 308 зарегистрированных = 38 с оплатой + 270 без неё; запрос ниже раскладывает эти 270 по каналам.

Второе свойство, за которое его выбирают: окно задаётся относительно каждой левой строки. Условие p.paid_at < u.signup_date + interval '14 days' внутри коррелированного подзапроса читает signup_date того пользователя, который сейчас проверяется. Через фиксированные даты такой сегмент не выразить — у каждого участника когорты своя граница.

Когорта первой недели июля после отрицательной проверки

Зарегистрировалось 308 человек, создали рабочее пространство 168, оплатили в первые 14 дней 38. Сегмент «без оплаты» — 270, и 38 + 270 даёт исходные 308.

Пользователей
Когорта недели без оплаты в первые 14 дней, по каналам
select u.channel, count(*) as users_without_payment
from users as u
where u.signup_date >= date '2026-07-01'
  and u.signup_date <  date '2026-07-08'
  and not exists (
    select 1
    from payments as p
    where p.user_id = u.user_id
      and p.paid_at >= u.signup_date
      and p.paid_at <  u.signup_date + interval '14 days'
  )
group by u.channel
order by u.channel;

NOT IN с одним NULL возвращает ноль строк и выглядит как «таких нет»

Самая дорогая ловушка этой темы выглядит как отсутствие проблемы. Запрос выполняется, ошибок нет, результат пустой — и аналитик делает вывод: пользователей без заказов не осталось.

Разберём на пяти строках. Пример условный и собран внутри запроса: в учебной базе пустых user_id нет. В demo_users лежат id от 1 до 5. В demo_orders — три строки с user_id 1, 3 и NULL (гостевой заказ без привязки к аккаунту). where user_id not in (select user_id from demo_orders) должен вернуть 2, 4 и 5, а возвращает ноль строк.

Причина в том, как not in раскрывается. Для пользователя 2 это 2 <> 1 and 2 <> 3 and 2 <> NULL. Первые два сравнения истинны, третье даёт UNKNOWN, потому что про неизвестное значение нельзя утверждать, что оно не равно двойке. TRUE and TRUE and UNKNOWN — это UNKNOWN, а where пропускает дальше только TRUE. Строка отбрасывается. То же происходит с 4 и 5, и с любым id: достаточно одного NULL в подзапросе, чтобы not in не вернул ничего никогда.

Положительная форма при этом работает. in раскрывается через OR: для пользователя 1 это 1 = 1 or 1 = 3 or 1 = NULL, и первое же TRUE делает всё выражение истинным независимо от UNKNOWN. Поэтому баг живёт только в отрицании — и проявляется ровно в тот момент, когда вы ищете «кого нет». Заметить его на глаз невозможно: от корректного «таких пользователей не найдено» пустой результат отличить нечем.

Лечится тремя способами. Добавить where user_id is not null в подзапрос — работает, но требует помнить про NULL каждый раз. Переписать на not exists — проверка существования строки не использует сравнение на неравенство, поэтому NULL в demo_orders.user_id просто не создаёт совпадения. Взять except — он сравнивает строки по правилам множеств, где NULL равен NULL. Первый вариант лечит симптом, второй и третий убирают причину.

Как четыре конструкции ведут себя на NULL в правой стороне
КонструкцияNULL справаДубли справаПоля справа доступны
NOT EXISTSне влияетне влияютнет
NOT INобнуляет результатне влияютнет
LEFT JOIN ... IS NULLзависит от выбранной колонкине влияют после фильтрада
EXCEPTNULL равен NULLубираются вместе с повтораминет
Пять пользователей, три заказа, один NULL
-- учебная вставка: пять пользователей и три заказа, один из них без user_id
with demo_users(user_id) as (values (1), (2), (3), (4), (5)),
     demo_orders(user_id) as (values (1), (3), (cast(null as integer)))
-- 0 строк вместо 2, 4, 5
select 1 as n, 'not in' as variant, count(*) as rows_found,
       string_agg(cast(user_id as varchar), ', ' order by user_id) as user_ids
from demo_users
where user_id not in (select user_id from demo_orders)
union all
-- 2, 4, 5 — три рабочих варианта
select 2, 'not in + is not null', count(*),
       string_agg(cast(user_id as varchar), ', ' order by user_id)
from demo_users
where user_id not in (select user_id from demo_orders where user_id is not null)
union all
select 3, 'not exists', count(*),
       string_agg(cast(u.user_id as varchar), ', ' order by u.user_id)
from demo_users as u
where not exists (select 1 from demo_orders as o where o.user_id = u.user_id)
union all
select 4, 'except', count(*),
       string_agg(cast(user_id as varchar), ', ' order by user_id)
from (
  select user_id from demo_users
  except
  select user_id from demo_orders
) as diff
order by n;
sqlЧто на самом деле вычисляется для user_id = 2 и для user_id = 1
select
  (2 <> 1) and (2 <> 3) and (2 <> null) as not_in_keeps_row,  -- null: строка 2 отброшена
  (1 = 1) or (1 = 3) or (1 = null)      as in_keeps_row;      -- true: строка 1 остаётся
Правило без исключений

Для отрицательной проверки по подзапросу пишите NOT EXISTS. NOT IN оставляйте только для явного списка литералов, в котором NULL быть не может.

LEFT JOIN ... IS NULL врёт при трёх типичных неточностях

Второй способ выразить anti-join — соединить через left join и оставить строки, где правая сторона пустая. Он удобнее not exists, когда нужно показать поля правой таблицы или разобраться, почему совпадений нет. Но у него три условия, без которых он даёт неправильный ответ.

Первое: колонка в is null должна быть гарантированно непустой в самой правой таблице. Если проверять необязательное поле — скажем, условный p.promo_code is null, которого в учебной базе нет, — в сегмент попадут и те, у кого оплаты не было, и те, у кого она была без промокода. Годится первичный ключ или ключ соединения.

Второе: все условия совпадения — тип оплаты, статус, окно — обязаны стоять в on. Вынесенное в where условие по правой таблице превращает соединение во внутреннее, и тогда is null в том же where не оставит ни одной строки: одна и та же строка не может одновременно иметь payment_type = 'first' и пустой user_id. Результат — стабильный ноль, который легко принять за пустой сегмент.

Третье: соединение до фильтра живёт на зерне «пользователь × оплата», и если добавить к нему агрегат, получится то же завышение, что с событиями. После where p.user_id is null дубли уходят сами — у несовпавшей левой строки ровно одна правая пара из NULL, — но до этого фильтра набор размножен, и любая промежуточная метрика по нему уже неверна.

Рабочая форма anti-join через LEFT JOIN
select u.channel, count(*) as users_without_payment
from users as u
left join payments as p
  on p.user_id = u.user_id
 and p.paid_at >= u.signup_date
 and p.paid_at <  u.signup_date + interval '14 days'
where u.signup_date >= date '2026-07-01'
  and u.signup_date <  date '2026-07-08'
  and p.user_id is null
group by u.channel
order by u.channel;
sqlТа же попытка с условием совпадения в WHERE: всегда 0 строк
select count(*) as rows_found
from users as u
left join payments as p on p.user_id = u.user_id
where p.payment_type = 'first'
  and p.user_id is null;

«Никогда не покупал» и «не купил за 14 дней» — разные сегменты

Большинство ошибок в anti-join не синтаксические, а методологические: запрос корректен, а вопрос подменён. Два условия, которые путают чаще всего, отличаются одной строкой в подзапросе и отвечают на разные задачи.

not exists без временных границ означает «за всю историю событий». Такой сегмент будет сжиматься сам по мере того, как старые пользователи когда-нибудь что-нибудь покупают, и годится разве что для списка тех, кто ни разу не дошёл до оплаты. Сегмент с окном от signup_date означает «не купил в первые две недели после регистрации» — он фиксирован во времени и подходит для сравнения когорт между собой.

Проверить, что реализовано именно нужное условие, помогает один случай: пользователь с покупкой на 15-й день. В сегменте «не купил за 14 дней» он обязан остаться, в сегменте «никогда не покупал» — обязан исчезнуть. Если поведение не то, дело в границах подзапроса, а не в данных. В когорте первой недели июля таких 22 человека: без оплаты за 14 дней — 270, без единой оплаты — 248.

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

Два сегмента одной когорты: «ни одной оплаты» и «без оплаты за 14 дней»
with first_payment as (       -- зерно: один пользователь
  select user_id, min(paid_at) as first_paid_at
  from payments
  group by user_id
)
select
  count(*)                                    as cohort,                 -- 308
  count(*) filter (where f.user_id is null)   as never_paid,             -- 248
  count(*) filter (
    where f.user_id is null
       or f.first_paid_at >= u.signup_date + interval '14 days'
  )                                           as no_payment_in_14_days   -- 270
from users as u
left join first_payment as f on f.user_id = u.user_id
where u.signup_date >= date '2026-07-01'
  and u.signup_date <  date '2026-07-08';
  • Левая база — кто участвует в вопросе и за какой период зарегистрирован.
  • Совпадение — какое событие или статус считается наступившим.
  • Окно — абсолютное или относительное, от даты каждой левой строки.
  • Ключ — по чему связаны стороны и уникален ли он слева.

Какой инструмент под какой вопрос

Четыре конструкции из этого гайда не конкурируют, а отвечают на разные вопросы. Выбор определяется тем, что должно оказаться в результате, и каким должно остаться зерно.

Решение по форме ответа
Что нужно в результатеКонструкцияЧто происходит с зерном
атрибуты из справочника 1:1JOIN по уникальному ключуне меняется
сумма или счётчик из дочерней таблицыGROUP BY в CTE, затем LEFT JOINне меняется
детализация дочерней таблицыJOIN без агрегациистановится зерном дочерней таблицы
признак наличия связиEXISTSне меняется
отсутствие связиNOT EXISTSне меняется
отсутствие связи плюс поля справаLEFT JOIN ... IS NULLне меняется после фильтра
разность двух наборов строкEXCEPTдубли теряются

Что проверить до того, как отчёт уйдёт в дашборд

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

  • Зерно результата названо словами, и после каждого JOIN фраза остаётся правдой.
  • Для каждой правой таблицы посчитано count(*) против count(distinct key).
  • Число строк до и после каждого соединения зафиксировано, рост объяснён.
  • SUM денежной колонки совпадает с суммой по исходной таблице за тот же период.
  • Число уникальных ключей слева не изменилось после соединения.
  • В left join все условия совпадения стоят в on, а не в where.
  • Ни один distinct не используется как средство убрать дубли от соединения.
  • В отрицательных проверках нет not in по подзапросу с возможным NULL.
  • Сумма взаимоисключающих сегментов равна размеру левой базы.

Практика: один вопрос, четыре реализации

Запустите в песочнице запрос с пятью условными заказами из раздела про два соединения. Он вернёт 17 000, 52 000 и 89 400. Затем уберите из вставки одно событие четвёртого заказа и до запуска посчитайте третий шаг: должно выйти 55 000, потому что четвёртый заказ занимает уже 4 строки, а не 8.

Дальше сверните позиции и события до order_id в двух CTE и проверьте, что сумма вернулась к 17 000, а строк снова пять. На учебной базе то же делает запрос из раздела про сумму на заказ: 30 639 вместо 284 913. Как размножение выглядит в плане запроса, показано в разборе EXPLAIN ANALYZE.

Вторая часть — про отрицание. В запросе из раздела про NOT IN уберите NULL из demo_orders и запустите его: все четыре варианта вернут 2, 4 и 5. Верните NULL и перезапустите. Три варианта ответят одинаково, один вернёт пустой результат — и это ровно тот случай, который в рабочем отчёте выглядит как корректный ответ «таких нет».

Последний шаг — окно. В когорте первой недели июля найдите 22 человека с первой оплатой на 15-й день или позже и проверьте, что они остались в сегменте «не оплатил за 14 дней» и пропали из сегмента «никогда не платил». После этого запишите в одну строку, какое именно условие реализует ваш запрос: эта формулировка и пойдёт в описание сегмента, которым потом будут пользоваться другие.

Итог и куда дальше

Когда проверка кардинальности показала, что нужны не атрибуты справочника, а все расхождения между двумя источниками, смотрите FULL JOIN и CROSS JOIN: первый оставляет три зоны сверки, второй достраивает ожидаемые пустые клетки. Когда в результате нужны именно пары строк одной таблицы, self join задаёт правило их образования и убирает двойной счёт.

Соединение — не способ добавить таблицу к запросу, а способ сменить зерно результата. Пока зерно совпадает с вопросом отчёта, метрики считаются правильно; как только соединение размножило левую сторону, любая сумма поверх набора отвечает на вопрос, который никто не задавал. Отсюда вся практика: измерить кардинальность до соединения, свернуть дочерние таблицы до нужного ключа, а для вопросов про наличие и отсутствие связи вообще не соединять — использовать exists и not exists.

И одно правило, которое дешевле запомнить, чем вывести заново: not in по подзапросу ломается от единственного NULL и ломается молча. Отрицательные проверки пишите через not exists — тогда у вас останется на один источник тихих расхождений меньше.

Продолжить чтение
Вся библиотека
Продуктовая аналитика30 июля 2026 г.19 мин
Три вложенных прямоугольника, внутренний закрашен.

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

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

Читать материал