JOIN без потерь и дублей: как соединение меняет зерно отчёта и выручку
Почему после JOIN выручка вырастает в разы: кардинальность связи, агрегация до соединения, выбор между JOIN и EXISTS, anti-join через NOT EXISTS и ловушка NOT IN с NULL.
Содержание статьи
В отчёте по выручке была 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_id | amount | позиций = строк после JOIN | вклад в SUM(o.amount) | доля в 52 000 |
|---|---|---|---|---|
| 1 | 1 200 | 1 | 1 200 | 2% |
| 2 | 3 400 | 2 | 6 800 | 13% |
| 3 | 900 | 1 | 900 | 2% |
| 4 | 8 600 | 4 | 34 400 | 66% |
| 5 | 2 900 | 3 | 8 700 | 17% |
| итого | 17 000 | 11 | 52 000 | 100% |
Два соединения подряд перемножаются, а не складываются
В реальном запросе рядом с позициями обычно оказывается третья таблица: события доставки, логи статусов, обращения в поддержку. Каждая из них добавляет свою кардинальность, и множители не суммируются — они перемножаются по каждому заказу отдельно.
Продолжим пример. Добавим к тем же пяти заказам события: у №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;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;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; -- 87LEFT 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 на месте события.
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; -- 3625IN сравнивает значения, 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 прямо называет вопрос — «существует ли связанная строка», — и в него удобно дописывать условия по окну и статусу.
В учебной базе 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.
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 справа | Дубли справа | Поля справа доступны |
|---|---|---|---|
| NOT EXISTS | не влияет | не влияют | нет |
| NOT IN | обнуляет результат | не влияют | нет |
| LEFT JOIN ... IS NULL | зависит от выбранной колонки | не влияют после фильтра | да |
| EXCEPT | 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;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, — но до этого фильтра набор размножен, и любая промежуточная метрика по нему уже неверна.
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;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.
Вторая методологическая деталь — левая база. «Пользователи без покупки» и «пользователи июльской когорты без покупки» дают разные числа, и разница растёт с возрастом продукта: в первом варианте в сегмент попадают все, кто зарегистрировался вчера и просто не успел. Границы левой базы лучше описывать в том же запросе, а не держать в фильтре дашборда.
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:1 | JOIN по уникальному ключу | не меняется |
| сумма или счётчик из дочерней таблицы | 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 — тогда у вас останется на один источник тихих расхождений меньше.
Материалы по теме

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

LEFT JOIN в SQL: отличие от INNER JOIN, NULL и условие в ON
LEFT JOIN в SQL на примерах: что возвращает, чем отличается от INNER JOIN, почему условие в WHERE теряет строки и как найти записи без пары.
Вопросы по SQL на собеседовании: 35 вопросов с короткими ответами
Теоретические вопросы по SQL для аналитика с ответами в два-пять предложений: ключи, NULL, JOIN, GROUP BY, окна, индексы, CTE, DAU и retention — с примерами на данных.