JOIN в SQL: INNER, LEFT и FULL на примере users, events и payments
Виды JOIN в SQL — INNER, LEFT, RIGHT, FULL и CROSS — на одной базе: как связать пользователей с событиями и платежами, не потерять сегменты и не завысить метрику.
Содержание статьи
В аналитике почти никогда не хватает одной таблицы. Пользователь лежит в 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 | Какие строки остаются | Строк | Когда нужен |
|---|---|---|---|
| INNER JOIN | только пары, совпавшие по ключу | 1 251 | факты, у которых обязана быть вторая сторона |
| LEFT JOIN | все строки левой таблицы; без пары — NULL справа | 4 952 | знаменатель метрики: все пользователи, платили они или нет |
| RIGHT JOIN | все строки правой таблицы; без пары — NULL слева | 1 251 | то же, что LEFT с переставленными таблицами |
| FULL JOIN | все строки обеих таблиц | 4 952 | сверка двух источников |
| CROSS JOIN | каждая строка с каждой, без условия | 5 770 863 | сетка «дата × сегмент» без пропусков |
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_id | users → events: один ко многим |
| events | одно событие | event_id | много событий одного пользователя |
| payments | один платёж | payment_id | много платежей одного пользователя |
INNER JOIN: оставить только совпадения
Начнём с вопроса: какие пользователи сделали core action? В учебной базе это создание отчёта, report_created; чтобы результат поместился на экран, берём зарегистрированных 1 июня — их 32. Для ответа достаточно соединить users с отфильтрованными events. Пользователи без подходящего события в результат не попадут — это и есть поведение INNER JOIN: в выдаче 15 строк из 32.
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;Если один пользователь сделал 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: оно выбирает когорту и пустых совпадений не трогает.
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, дублирующего ключа или фильтра в неправильном месте.
Напишите одной фразой, что означает строка слева, что означает строка справа и что должна означать строка результата. Если три ответа разные и это не запланировано, сначала агрегируйте одну из сторон.
- Соединить две 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. Чем раньше обнаружено размножение строк, тем дешевле исправление.
| Инвариант | Ожидание |
|---|---|
| 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 | Реальная выручка | Завышение |
|---|---|---|---|
| organic | 135 480 | 14 114 | ×9,6 |
| referral | 97 754 | 9 326 | ×10,5 |
| partner | 38 521 | 4 732 | ×8,1 |
| paid_search | 13 158 | 2 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 + coalesce | NULL вместо нуля в метрике |
| Сверить два источника | FULL OUTER JOIN | строки без пары с обеих сторон |
| Календарь без пропусков | CROSS JOIN с датами | размер декартова произведения |
| Найти отсутствие события | NOT EXISTS | период, в котором ищем отсутствие |
| Взять одну связанную запись | LATERAL + LIMIT 1 | детерминированная сортировка |
Материалы по теме

LEFT JOIN в SQL: отличие от INNER JOIN, NULL и условие в ON
LEFT JOIN в SQL на примерах: что возвращает, чем отличается от INNER JOIN, почему условие в WHERE теряет строки и как найти записи без пары.

SQL-проверки качества данных: дубли, пропуски и поиск расхождений
Чеклист SQL-проверок перед отчётом: дубли и пропуски в ключах, сверка метрики по слоям, разбор расхождения выручки и ревью чужого запроса до того, как поверить его числу.
Ошибки в SQL-запросах: тексты сообщений, причины и исправления
Частые ошибки SQL с дословными текстами PostgreSQL и DuckDB: GROUP BY, column does not exist, ambiguous, division by zero, типы и даты, и пять запросов, которые молча врут.