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

JOIN в SQL: INNER, LEFT и FULL на примере users, events и payments

Виды JOIN в SQL — INNER, LEFT, RIGHT, FULL и CROSS — на одной базе: как связать пользователей с событиями и платежами, не потерять сегменты и не завысить метрику.

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

В аналитике почти никогда не хватает одной таблицы. Пользователь лежит в users, его действия — в events, платежи — в payments. JOIN позволяет собрать эти факты в один расчёт, но вместе с удобством приносит риск: после соединения строк может стать больше, а сумма или число пользователей — незаметно завыситься. Разберём, как выбирать тип JOIN и контролировать grain на каждом шаге.

Коротко

JOIN соединяет строки из двух источников по ключу. Для продуктовой аналитики важно помнить не только условие ON, но и кардинальность: одна строка users может соединиться с несколькими events или payments.

  • INNER JOIN оставляет только совпавшие строки с обеих сторон.
  • LEFT JOIN сохраняет все строки из таблицы слева и добавляет совпадения справа.
  • До JOIN зафиксируйте grain каждой таблицы и ожидаемый grain результата.
  • После соединения проверьте COUNT(*) и COUNT(DISTINCT user_id).
  • Две таблицы one-to-many лучше сначала агрегировать до нужного grain, а потом соединять.

Виды JOIN в SQL: INNER, LEFT, RIGHT, FULL и CROSS

Основных видов JOIN пять. Четыре из них различаются одним: что происходит со строками, которым не нашлось пары; пятый, CROSS JOIN, обходится без условия. Проще всего увидеть это на числах. В полной учебной базе из песочницы симулятора 4 613 пользователей и 1 251 платёж; платили 912 человек, у остальных 3 701 платежей нет. Соединим users и payments по user_id каждым способом и посчитаем строки результата.

INNER JOIN вернул 1 251 строку: по одной на платёж, пользователи без оплат исчезли. LEFT JOIN вернул 4 952: те же 1 251 и ещё 3 701 пользователь с NULL на месте платежа. RIGHT JOIN снова дал 1 251, потому что у каждого платежа есть пользователь, и по той же причине FULL JOIN совпал с левым. У CROSS JOIN условия нет, он соединяет каждую строку с каждой: 4 613 × 1 251 = 5 770 863 строки.

Виды JOIN: что остаётся в результате соединения users и payments
Вид JOINКакие строки остаютсяСтрокКогда нужен
INNER JOINтолько пары, совпавшие по ключу1 251факты, у которых обязана быть вторая сторона
LEFT JOINвсе строки левой таблицы; без пары — NULL справа4 952знаменатель метрики: все пользователи, платили они или нет
RIGHT JOINвсе строки правой таблицы; без пары — NULL слева1 251то же, что LEFT с переставленными таблицами
FULL JOINвсе строки обеих таблиц4 952сверка двух источников
CROSS JOINкаждая строка с каждой, без условия5 770 863сетка «дата × сегмент» без пропусков
Пять видов JOIN на одних и тех же таблицах: сколько строк в результате
select
  (select count(*) from users u inner join payments p on p.user_id = u.user_id) as inner_rows,  -- 1251
  (select count(*) from users u left join payments p on p.user_id = u.user_id)  as left_rows,   -- 4952
  (select count(*) from users u right join payments p on p.user_id = u.user_id) as right_rows,  -- 1251
  (select count(*) from users u full join payments p on p.user_id = u.user_id)  as full_rows,   -- 4952
  (select count(*) from users u cross join payments p)                          as cross_rows;  -- 5770863

Сначала нарисуйте связи

В простом учебном наборе users.user_id — ключ пользователя. В events один пользователь может иметь много строк, потому что одна строка означает одно событие. В payments тоже может быть несколько платежей на пользователя. Поэтому связь users → events и users → payments имеет тип one-to-many.

Если соединить users сразу и с events, и с payments, пользователь с тремя событиями и двумя платежами даст шесть строк промежуточного результата. Это не «ошибка JOIN»: база честно показала все комбинации. Ошибка появляется, когда эти строки затем принимают за уникальных пользователей или складывают платежи повторно.

Минимальная карта учебных таблиц
ТаблицаОдна строкаКлючПример связи
usersодин пользовательuser_idusers → events: один ко многим
eventsодно событиеevent_idмного событий одного пользователя
paymentsодин платёжpayment_idмного платежей одного пользователя

INNER JOIN: оставить только совпадения

Начнём с вопроса: какие пользователи сделали core action? В учебной базе это создание отчёта, report_created; чтобы результат поместился на экран, берём зарегистрированных 1 июня — их 32. Для ответа достаточно соединить users с отфильтрованными events. Пользователи без подходящего события в результат не попадут — это и есть поведение INNER JOIN: в выдаче 15 строк из 32.

Пользователи когорты 1 июня, создавшие отчёт
select distinct
  u.user_id,
  u.channel,
  cast(e.event_time as date) as event_date
from users as u
join events as e
  on e.user_id = u.user_id
where e.event_name = 'report_created'
  and u.signup_date = date '2026-06-01'
order by event_date, u.user_id;
Почему здесь нужен DISTINCT

Если один пользователь сделал core action несколько раз, JOIN вернёт несколько строк. DISTINCT убирает повторы только в этом результате. В учебной базе report_created записан у пользователя не больше одного раза, поэтому здесь DISTINCT ничего не убирает: строк 15 и с ним, и без него. С app_open тот же запрос вернёт для этих 32 человек 294 строки, и DISTINCT их не сократит: открытия приходятся на разные дни, а дата есть в выдаче. Для метрики лучше явно определить зерно и считать COUNT(DISTINCT u.user_id), а не полагаться на случайную уникальность выдачи.

LEFT JOIN: не потерять пользователей без событий

Теперь другой вопрос: сколько пользователей из каждого канала вообще не сделали core action? Здесь users должна остаться главной таблицей, поэтому используем LEFT JOIN. Строки без совпадения справа сохранятся, а поля events будут равны NULL. На той же когорте 1 июня запрос возвращает все 32 строки, у 17 дата пустая: по семь в organic и partner, три в referral.

Обратите внимание, где стоит условие e.event_name = .... Если поставить его в WHERE, строки с NULL справа будут отброшены, и LEFT JOIN фактически начнёт вести себя как INNER JOIN. Условия по правой таблице, которые должны сохранить «пустые» совпадения, обычно ставят в ON. Условие по левой таблице, u.signup_date, остаётся в WHERE: оно выбирает когорту и пустых совпадений не трогает.

Когорта 1 июня и факт создания отчёта
select
  u.user_id,
  u.channel,
  cast(e.event_time as date) as event_date
from users as u
left join events as e
  on e.user_id = u.user_id
 and e.event_name = 'report_created'
where u.signup_date = date '2026-06-01'
order by u.user_id;

Агрегируйте до JOIN, если считаете деньги

Самая надёжная защита от размножения — сначала привести каждую таблицу к зерну, которое нужно в результате. Например, сначала посчитать платежи по пользователю, а события — отдельно по пользователю, и только потом присоединить обе сводки к users.

Перед финальным SELECT обе промежуточные таблицы имеют grain «один пользователь». Поэтому JOIN больше не создаёт комбинации «каждый платёж × каждое событие». COALESCE превращает отсутствие платежей или событий в ноль, чтобы результат было удобно читать в BI. В примере считаем открытия приложения и выводим десять пользователей с наибольшей выручкой.

Выручка и открытия приложения по пользователям без двойного счёта
with payments_by_user as (
  select
    user_id,
    sum(amount) as revenue
  from payments
  group by user_id
),
events_by_user as (
  select
    user_id,
    count(*) filter (where event_name = 'app_open') as app_opens
  from events
  group by user_id
)
select
  u.user_id,
  u.channel,
  coalesce(p.revenue, 0) as revenue,
  coalesce(e.app_opens, 0) as app_opens
from users as u
left join payments_by_user as p using (user_id)
left join events_by_user as e using (user_id)
order by revenue desc, u.user_id
limit 10;

Как найти размножение строк

Если после JOIN сумма выручки внезапно выросла, не начинайте с ручной правки коэффициента. Сначала сравните результат до и после соединения. Полезно посмотреть пользователей с максимальным числом строк и проверить, сколько событий и платежей у каждого из них. На учебной базе у первого в списке 57 строк: 19 событий × 3 платежа.

Три контрольных числа
ПроверкаЧто показывает
COUNT(*) до JOINразмер исходной выборки
COUNT(*) после JOINсколько строк стало после комбинаций
COUNT(DISTINCT user_id)сколько пользователей реально осталось
Контроль количества строк после соединения
select
  u.user_id,
  count(*) as joined_rows,
  count(distinct e.event_id) as events,
  count(distinct p.payment_id) as payments
from users as u
left join events as e using (user_id)
left join payments as p using (user_id)
group by u.user_id
having count(*) > 1
order by joined_rows desc, u.user_id
limit 10;

Частые ошибки в JOIN

JOIN ломается не только из-за неправильного синтаксиса. Чаще запрос запускается успешно, но меняет смысл метрики из-за неверной стороны LEFT JOIN, дублирующего ключа или фильтра в неправильном месте.

Правило перед JOIN

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

  • Соединить две one-to-many таблицы напрямую и посчитать сумму после размножения строк.
  • Использовать INNER JOIN, когда нужны и пользователи без событий или платежей.
  • Поставить фильтр правой таблицы в WHERE и потерять строки без совпадений.
  • Соединить по неуникальному полю вроде channel вместо user_id.
  • Не учитывать временную логику: событие должно произойти до платежа, а не когда-нибудь в истории.
  • Считать COUNT(*) как количество пользователей после соединения с events.

JOIN по времени: факт связи ещё не означает причинность

Соединить пользователя с событием по user_id недостаточно, если вопрос связан с периодом. Для кампании или эксперимента событие должно попасть в окно после exposure, а платеж — после целевого действия. Иначе в расчёт попадут старые факты из истории пользователя.

Временное условие лучше писать явно в ON, если нужно сохранить пользователей без подходящего события. Условие в WHERE после LEFT JOIN удалит NULL-строки и изменит смысл запроса. После соединения покажите долю пользователей с совпадением и диапазон дат найденных фактов.

Для нескольких событий заранее решите, нужен ли первый факт, любой факт или факт в каждой сессии. MIN(event_time) отвечает только за первый момент. Если нужны свойства именно первой строки, используйте оконную функцию или отдельный CTE до JOIN. В примере окно — дни 1–7 после регистрации: из 32 человек когорты 1 июня в приложение вернулись 29, у троих NULL.

Присоединить первое возвращение в первую неделю после регистрации
select
  u.user_id,
  u.signup_date,
  min(cast(e.event_time as date)) as first_return
from users as u
left join events as e
  on e.user_id = u.user_id
 and e.event_name = 'app_open'
 and e.event_time >= u.signup_date + 1
 and e.event_time <  u.signup_date + 8
where u.signup_date = date '2026-06-01'
group by u.user_id, u.signup_date
order by u.user_id;

Кардинальность нужно проверять до финансового вывода

Перед JOIN посчитайте уникальность ключа на правой стороне. Если users должен быть one-to-one по user_id, проверьте это отдельно. Если правая таблица many, агрегируйте её до уровня результата. validate есть в pandas, а в SQL ту же идею нужно выразить контрольным запросом на дубли и сравнением строк до и после соединения.

Особенно опасно соединять events и payments одновременно. Пользователь с пятью событиями и двумя платежами создаёт десять комбинаций. count(distinct user_id) может остаться правильным, а revenue будет завышен. Уникальный count не спасает денежные агрегаты.

После исправления напишите короткую проверку: сумма payments_by_user до JOIN равна сумме revenue после JOIN, а количество пользователей в users не изменилось при LEFT JOIN. Такие инварианты проще поддерживать, чем объяснять расхождение в конце месяца.

Что сверять после соединения
КонтрольОжидание
строки users до/после LEFT JOINстрок не стало меньше
distinct user_idсовпадает с исходной базой
sum payments_by_userне меняется после присоединения
orphan user_idдоля объяснена или вынесена в quality check

Мини-практика и следующий шаг

Возьмите users и events в песочнице симулятора SQL-аналитика. Сначала посчитайте пользователей, создавших отчёт (report_created), через JOIN, затем перепишите запрос на LEFT JOIN и найдите тех, у кого такого события нет. После этого добавьте payments и сравните результат с вариантом, где платежи агрегированы заранее.

ON и WHERE: где оставить условие правой таблицы

Разница между ON и WHERE особенно важна для LEFT JOIN. Условие в ON ограничивает совпадения справа, но сохраняет строку слева. Условие в WHERE применяется уже после соединения и может удалить строки, где справа оказался NULL. Поэтому запросы с одинаковым текстом фильтра могут отвечать на разные вопросы.

Например, «все пользователи и их core action в июне» требует условия даты в ON. Если поставить дату в WHERE, пользователи без июньского события исчезнут, и результат уже будет списком активных пользователей, а не полной базой с признаком активности.

Один фильтр — два разных смысла
Место условияЧто сохраняется
ON e.event_time >= startвсе users, справа только события после start
WHERE e.event_time >= startтолько users с событием после start
ON e.event_name = 'report_created'все users, справа только core action
WHERE e.event_name = 'report_created'только users с core action

USING, ON и явные ключи

USING (user_id) удобен, когда колонка с ключом называется одинаково в обеих таблицах. ON e.user_id = u.user_id длиннее, но лучше показывает связь, если ключи имеют разные названия, составной смысл или в запросе много таблиц. Для учебного примера USING сокращает шум, для критического отчёта явное условие часто легче проверять.

Не соединяйте по полю, которое выглядит похожим, но не является ключом. Канал, имя, дата или email могут повторяться. Если связь должна быть по нескольким полям, перечислите все условия и проверьте, как меняется кардинальность после каждого из них.

  • Ключ уникален на стороне, которую считаете one-to-one?
  • Типы ключей одинаковы: integer, text или UUID?
  • Нужна ли связь по дате, версии или статусу дополнительно к user_id?
  • Есть ли NULL в ключе и как он должен вести себя?

JOIN для признака, а не для списка событий

Если задача — добавить пользователю признак «делал core action», не присоединяйте все события как строки. Сначала соберите одну строку на user_id: max по флагу, min по первой дате или count(*) по периоду. Тогда итоговый JOIN сохраняет зерно users и становится безопаснее для последующих метрик.

Флаг особенно удобен для сегментации: пользователи с действием и без него, payer и non-payer, новый и возвращающийся. Если нужен список всех событий, оставляйте event grain и не смешивайте его с пользовательской агрегацией в одном SELECT.

Добавить пользователю признак активности
with report_users as (   -- одна строка на пользователя
  select
    user_id,
    min(cast(event_time as date)) as first_report_date
  from events
  where event_name = 'report_created'
  group by user_id
)
select
  u.user_id,
  u.channel,
  r.first_report_date,
  (r.user_id is not null) as has_report
from users as u
left join report_users as r using (user_id)
where u.signup_date = date '2026-06-01'
order by u.user_id;

Проверьте JOIN контрольными инвариантами

У хорошего запроса есть ожидаемые свойства. После LEFT JOIN users не должно стать меньше. После присоединения агрегированной таблицы users не должно стать больше, если справа один ряд на user_id. Сумма платежей до и после соединения должна совпадать. Эти проверки превращают JOIN из догадки в контролируемый шаг.

Сохраните такие сверки рядом с запросом или в data quality наборе. Когда схема изменится, проверка покажет, что нарушилась уникальность ключа или появилась новая связь one-to-many. Чем раньше обнаружено размножение строк, тем дешевле исправление.

Контрольные инварианты после JOIN
ИнвариантОжидание
users count после LEFT JOINне меньше исходного и не больше без причины
distinct user_idсовпадает с ожидаемой базой
sum платежейне меняется после безопасного соединения
строк на user_idодно значение в агрегированной правой таблице

Мини-кейс: выручка выросла из-за двух one-to-many таблиц

Аналитик соединяет users, events и payments по user_id, а затем получает выручку по каналам. На учебной базе итог такого запроса — 284 913 вместо 30 639 по платёжному реестру, в 9,3 раза больше. Проверка показывает, что у плательщика в среднем 8,9 события: каждый платёж размножился на количество событий. COUNT(DISTINCT user_id) выглядел правдоподобно, поэтому проблема обнаружилась только на сумме.

Исправление — агрегировать payments и events отдельно до user_id, затем соединить две сводки с users. После этого сверить сумму с исходной payments. Такой пример полезен как правило для всей серии: правильный JOIN начинается не с ON, а с определения зерна результата.

Сквозной разбор: сколько денег принёс каждый канал

Разберём эту ошибку целиком на полной учебной базе из песочницы: 4 613 пользователей, 35 341 событие и 1 251 платёж. Числа в заголовке следующего раздела — тот же запрос на срезе первой недели июня из первых глав симулятора: там 18 пользователей, 54 события и 9 платежей, и итог легко пересчитать руками. Срез — отдельный маленький набор, а не фильтр полной базы по дате: в песочнице запрос вернёт числа полной базы.

Вопрос звучит буднично: сколько выручки принёс каждый канал привлечения. Данные лежат в трёх таблицах — канал в users, деньги в payments, активность в events. Первый порыв — соединить всё сразу, чтобы «данные были под рукой».

Наивный вариант: соединяем всё и суммируем
SELECT
  u.channel,
  sum(p.amount) AS revenue
FROM users u
JOIN events   e ON e.user_id = u.user_id
JOIN payments p ON p.user_id = u.user_id
GROUP BY u.channel
ORDER BY revenue DESC, u.channel;

Почему получилось 606 вместо 125

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

Масштаб искажения видно только при сверке с первоисточником. На срезе первой недели июня реальная выручка organic — 125, а соединение через события даёт 606: почти пятикратное завышение. На полной базе событий на человека больше, и разрыв шире: 135 480 вместо 14 114. И завышение неравномерное — сильнее всего раздуваются активные каналы, то есть именно те, которые вы собираетесь хвалить в отчёте. Меньше всех завышен paid_search: у его плательщиков в среднем 5,4 события против 9,1 у organic.

Обратите внимание, что count(DISTINCT user_id) в том же запросе остался бы правильным: 410 плательщиков organic, как и в payments. Поэтому ошибка живёт долго: число пользователей сходится, а деньги — нет, и подозрение падает на что угодно, кроме соединения.

Что даёт наивное соединение на полной учебной базе
КаналЧерез JOIN с eventsРеальная выручкаЗавышение
organic135 48014 114×9,6
referral97 7549 326×10,5
partner38 5214 732×8,1
paid_search13 1582 467×5,3

Правильная форма: сначала свернуть, потом соединить

Лечение всегда одно: привести каждый источник к зерну «одна строка на пользователя» до соединения. Тогда JOIN связывает наборы один-к-одному и не может ничего размножить.

Заодно обратите внимание на LEFT JOIN и coalesce. На срезе первой недели июня канал partner не дал ни одного платежа. При обычном JOIN он просто исчез бы из отчёта, и в таблице каналов оказалось бы три строки вместо четырёх — а «нет выручки» это как раз тот факт, ради которого отчёт и делается. На полной базе платят все четыре канала, но LEFT JOIN нужен и здесь: без него signups считал бы только плательщиков — 912 человек вместо 4 613.

Выручка и плательщики по каналам без размножения строк
WITH revenue_by_user AS (        -- зерно: один пользователь
  SELECT user_id, sum(amount) AS revenue
  FROM payments
  GROUP BY user_id
)
SELECT
  u.channel,
  count(*)                        AS signups,
  count(r.user_id)                AS payers,
  coalesce(sum(r.revenue), 0)     AS revenue
FROM users u
LEFT JOIN revenue_by_user r ON r.user_id = u.user_id
GROUP BY u.channel
ORDER BY revenue DESC, u.channel;

Проверка, которая ловит это до отчёта

Есть простая контрольная сумма: итог по каналам обязан совпадать с суммой в самой таблице платежей. Если не совпадает — соединение размножило строки, и дальше можно не смотреть. На полной учебной базе обе величины дают 30 639.

Вторая проверка — количество строк. Если левая таблица содержит 4 613 пользователей, то и результат соединения до группировки должен содержать 4 613 строк. Любое превышение означает, что справа нашлось несколько совпадений на одного человека.

Эти две проверки занимают меньше минуты и закрывают самый дорогой класс ошибок в аналитике: не «запрос не работает», а «запрос работает и уверенно врёт».

Две контрольные проверки перед публикацией
WITH revenue_by_user AS (
  SELECT user_id, sum(amount) AS revenue
  FROM payments
  GROUP BY user_id
)
SELECT
  -- 1. итог после соединения против первоисточника
  (SELECT sum(amount) FROM payments) AS revenue_fact,
  sum(r.revenue)                     AS revenue_joined,
  -- 2. соединение не должно менять число строк слева
  (SELECT count(*) FROM users)       AS users_rows,
  count(*)                           AS joined_rows
FROM users u
LEFT JOIN revenue_by_user r ON r.user_id = u.user_id;

Когда ключи «одинаковые», а соединение пустое

Отдельный сорт боли — JOIN, который отработал и вернул ноль строк или подозрительно мало. Ключи выглядят одинаково на экране, но базе они не равны.

Самые частые причины три. Разные типы: user_id пришёл из выгрузки текстом, а в основной таблице он число — сравнение либо падает, либо молча не находит пар. Невидимые символы: пробел в конце или неразрывный пробел из Excel делают '42 ' и '42' разными значениями. И регистр: идентификаторы вроде почты или кода кампании в одной системе приведены к нижнему регистру, а в другой сохранены как ввёл пользователь.

Диагностика простая: посчитайте, сколько ключей из левой таблицы вообще встречаются в правой, и посмотрите на несколько «непопавших» значений глазами. Чинить лучше на входе — привести ключ к одному типу и виду в слое очистки, а не подставлять trim(lower(...)) в каждое соединение по всему проекту.

Сколько ключей действительно находит пару
SELECT
  count(*)                          AS left_rows,
  count(p.user_id)                  AS matched_rows,
  count(*) - count(p.user_id)       AS unmatched_rows
FROM users u
LEFT JOIN (SELECT DISTINCT user_id FROM payments) p
  ON p.user_id = u.user_id;

Остальные виды соединений: когда они действительно нужны

В повседневной аналитике хватает INNER и LEFT. Остальные формы полезно знать, чтобы узнавать их в чужом коде и не тянуть туда, где они не нужны.

Что каждая из них делает со строками без пары, показано в таблице в начале статьи. RIGHT JOIN в запросах почти не пишут: читать смешанные направления тяжело, проще переставить таблицы и оставить LEFT. FULL OUTER JOIN уместен при сверке двух источников, которые должны совпадать. CROSS JOIN нужен осознанно — например, чтобы построить календарь дат и получить нули в днях без активности.

Отдельно стоит анти-соединение: «пользователи, у которых нет ни одного события». Его выражают через NOT EXISTS или через LEFT JOIN с проверкой IS NULL по ключу правой таблицы. Формально это тоже JOIN, но задача обратная — найти отсутствие связи.

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

Какое соединение под какую задачу
ЗадачаФормаНа что смотреть
Оставить только парыINNER JOINкто исчезает из отчёта
Сохранить всех слеваLEFT JOIN + coalesceNULL вместо нуля в метрике
Сверить два источникаFULL OUTER JOINстроки без пары с обеих сторон
Календарь без пропусковCROSS JOIN с датамиразмер декартова произведения
Найти отсутствие событияNOT EXISTSпериод, в котором ищем отсутствие
Взять одну связанную записьLATERAL + LIMIT 1детерминированная сортировка
Продолжить чтение
Вся библиотека