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

LEFT JOIN в SQL: отличие от INNER JOIN, NULL и условие в ON

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

КейсПрактика11 октября 2026 г.14 мин

Продакт просит долю платящих по каналам привлечения. Вы соединяете users с payments обычным JOIN, группируете по каналу и получаете странную таблицу: в organic 410 пользователей и 410 плательщиков, в paid_search — 86 и 86. Конверсия в оплату 100% в каждом канале. Запрос отработал без ошибки, просто все, кто не платил, исчезли ещё на шаге соединения. Чтобы они остались в результате, нужен LEFT JOIN. Ниже — что именно он возвращает, откуда берутся NULL и на каких четырёх местах запрос с LEFT JOIN чаще всего даёт неверную цифру.

Коротко

LEFT JOIN возвращает все строки левой таблицы. Если для строки нашлась пара справа, столбцы правой таблицы заполнены; если не нашлась — в них NULL.

  • INNER JOIN оставляет только строки с парой, LEFT JOIN — ещё и строки левой таблицы без пары.
  • Условие по правой таблице в WHERE выбрасывает строки без пары, и LEFT JOIN начинает вести себя как INNER. Такое условие пишут в ON.
  • LEFT JOIN … WHERE правая.ключ IS NULL находит строки, у которых пары нет: пользователей без платежей, товары без заказов.
  • count(*) после LEFT JOIN считает и строки без пары, count(правая.столбец) — только найденные.
  • Если справа несколько строк на один ключ, строка слева повторится столько же раз. Число строк в результате может оказаться больше, чем в левой таблице.
  • LEFT JOIN и LEFT OUTER JOIN — одно и то же. RIGHT JOIN — тот же LEFT JOIN с переставленными таблицами.

Что возвращает LEFT JOIN

Левая таблица — та, что стоит в FROM перед словом LEFT JOIN, правая — та, что после. База берёт каждую строку левой таблицы и ищет для неё строки правой, для которых условие в ON истинно. Нашлась одна — в результате одна строка. Нашлось две — две. Не нашлось ни одной — строка всё равно попадает в результат, а на месте столбцов правой таблицы стоит NULL.

В учебной базе это видно на шести пользователях с номерами от 36 до 41. Платил из них только пользователь 39, причём дважды. Запрос возвращает семь строк: пять пользователей без платежей с пустыми payment_id, paid_at, amount и две строки пользователя 39.

С обычным JOIN тот же запрос вернул бы две строки, обе про пользователя 39. Остальные пятеро в таблице users есть, но по результату этого не понять.

Результат: 7 строк на 6 пользователей
user_idchannelpayment_idpaid_atamount
36organicNULLNULLNULL
37organicNULLNULLNULL
38organicNULLNULLNULL
39partner42026-06-0839.00
39partner2412026-07-0839.00
40referralNULLNULLNULL
41referralNULLNULLNULL
Шесть пользователей и их платежи: у пятерых справа NULL
select
  u.user_id,
  u.channel,
  p.payment_id,
  p.paid_at,
  p.amount
from users as u
left join payments as p on p.user_id = u.user_id
where u.user_id between 36 and 41
order by u.user_id, p.paid_at;

Чем LEFT JOIN отличается от INNER JOIN

Разница в одном: что происходит со строкой левой таблицы, у которой нет пары. INNER JOIN её отбрасывает, LEFT JOIN оставляет. Если пара есть у каждой строки, оба соединения вернут одинаковый результат.

На всей базе это выглядит так. В users 4 613 строк, в payments 1 251 платёж от 912 человек. INNER JOIN возвращает 1 251 строку и 912 пользователей. LEFT JOIN — 4 952 строки: те же 1 251 плюс 3 701 строка пользователей, которые ни разу не платили. Пользователей в результате 4 613, как в исходной таблице.

Вернёмся к доле платящих. С LEFT JOIN знаменатель на месте, и каналы наконец различаются: в referral платит 26,8% зарегистрированных, в paid_search — 8,5%. Обратите внимание на два разных счётчика: count(distinct u.user_id) считает всех пользователей канала, count(distinct p.user_id) — только тех, у кого нашёлся платёж, потому что count пропускает NULL.

Выбор между соединениями — это вопрос о том, кто должен быть в знаменателе. Считаете средний чек плательщика — подойдёт INNER JOIN, неплатившие вам не нужны. Считаете конверсию, долю, охват или просто хотите список всех с пометкой «есть или нет» — нужен LEFT JOIN.

Один и тот же запрос с JOIN и с LEFT JOIN
channelusers с JOINusers с LEFT JOINpayerspayer_pct с LEFT JOIN
organic410174741023,5
referral278103727826,8
paid_search861015868,5
partner13881413817,0
Доля платящих по каналам: в знаменателе все зарегистрированные
select
  u.channel,
  count(distinct u.user_id) as users,
  count(distinct p.user_id) as payers,
  round(cast(100.0 * count(distinct p.user_id) / count(distinct u.user_id) as numeric(18, 6)), 1) as payer_pct
from users as u
left join payments as p on p.user_id = u.user_id
group by u.channel
order by users desc;

Откуда берётся NULL после LEFT JOIN и что с ним делать

NULL в правых столбцах означает «пары не нашлось». Это не ноль и не пустая строка, и агрегаты обращаются с ним по-своему: count(столбец) его пропускает, sum и avg тоже. Если в группе все значения NULL, count вернёт 0, а sum — NULL.

Вот как это выглядит в отчёте. Берём регистрации последних дней августа и выручку с этих пользователей. 27, 28 и 29 августа зарегистрировались 87, 88 и 38 человек, и никто из них ещё не заплатил. В столбце payments у этих дней честный ноль, а в столбце revenue — пусто. На графике такой день выпадет, а не опустится до нуля.

Лечится это функцией coalesce: она возвращает первый аргумент, который не NULL. coalesce(sum(p.amount), 0) даёт ноль там, где платежей не было. Ставьте её снаружи агрегата, а не внутри: так видно, что ноль относится к итогу группы.

Одно предупреждение. coalesce уместен для сумм и количеств, но не для средних. Средний чек дня без платежей — не ноль, его просто нет, и NULL здесь правильный ответ. Подробнее о NULL и coalesce — в статье про CASE и COALESCE.

Результат: три последних дня без единого платежа
signup_dateuserspaymentsrevenuerevenue_filled
2026-08-2586496.0096.00
2026-08-26875145.00145.00
2026-08-27870NULL0.00
2026-08-28880NULL0.00
2026-08-29380NULL0.00
Регистрации и выручка по дням: sum возвращает NULL, coalesce превращает его в ноль
select
  u.signup_date,
  count(distinct u.user_id) as users,
  count(p.payment_id) as payments,
  sum(p.amount) as revenue,
  coalesce(sum(p.amount), 0) as revenue_filled
from users as u
left join payments as p on p.user_id = u.user_id
where u.signup_date >= date '2026-08-25'
group by u.signup_date
order by u.signup_date;

Почему LEFT JOIN превращается в INNER JOIN: условие в ON и в WHERE

Это главная ловушка LEFT JOIN, и попадают в неё не только новички. Задача: по каждому каналу показать, сколько пользователей всего и сколько из них платили в августе. Кажется естественным дописать where p.paid_at >= date '2026-08-01'. Запрос выполнится, но в organic окажется 283 пользователя вместо 1 747.

Причина в порядке выполнения. Сначала база собирает соединение: у пользователя без платежей в p.paid_at стоит NULL. Потом применяет WHERE. Сравнение NULL с датой не даёт истину, и строка отбрасывается. Заодно пропадают те, кто платил только в июне и июле. Остались только строки с парой, то есть получился INNER JOIN, написанный длиннее.

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

Правило короткое. Условие про правую таблицу, которое описывает, что именно присоединять, — в ON. Условие про левую таблицу — в WHERE: оно сужает сам список, и это обычно то, чего вы хотите. Проверка IS NULL по правой таблице — тоже в WHERE, о ней следующий раздел.

То же условие в ON и в WHERE
channelusers, условие в ONusers, условие в WHEREaugust_payers
organic1747283283
referral1037198198
paid_search10157171
partner8149090
Плательщики августа по каналам: условие на дату стоит в ON
select
  u.channel,
  count(distinct u.user_id) as users,
  count(distinct p.user_id) as august_payers
from users as u
left join payments as p
  on p.user_id = u.user_id
 and p.paid_at >= date '2026-08-01'
group by u.channel
order by users desc;
Как заметить ошибку без чтения запроса

Сравните число строк или пользователей до соединения и после. В users 4 613 человек, а после LEFT JOIN с условием в WHERE их осталось 642. LEFT JOIN не может уменьшить левую таблицу, значит, строки съел фильтр.

Как найти строки без пары: LEFT JOIN с IS NULL и NOT EXISTS

Раз у строк без пары справа стоит NULL, их можно отобрать: where p.user_id is null. Такой приём называют anti-join. Он отвечает на вопросы «у кого нет»: кто зарегистрировался и не заплатил, какие товары ни разу не заказывали, каким клиентам не назначен менеджер.

В учебной базе без единого платежа 3 701 пользователь: 1 337 в organic, 929 в paid_search, 759 в referral и 676 в partner. Проверять на NULL нужно столбец, который у найденной пары пустым быть не может: ключ соединения или первичный ключ правой таблицы. Если проверить p.amount is null, а в данных встретится платёж с незаполненной суммой, он попадёт в «неплатившие».

Здесь условие в ON и условие в WHERE работают вместе. Чтобы найти пользователей без активной подписки, признак s.status = 'active' ставится в ON: он сужает круг того, что считается парой. А s.user_id is null в WHERE отбирает тех, у кого такой пары нет. Таких 4 098: все, кроме 515 человек с активной подпиской.

Ту же задачу решает NOT EXISTS, и результат совпадает: 3 701. Запись с NOT EXISTS читается ближе к формулировке задачи, а ещё она не размножает строки, если справа несколько записей на ключ. LEFT JOIN с IS NULL удобнее, когда соединение в запросе уже есть. А вот NOT IN с подзапросом лучше не использовать: один NULL в списке — и запрос вернёт пустой результат. Разбор с примерами — в статье про EXISTS и IN.

Пользователи без платежей по каналам: LEFT JOIN и IS NULL
select
  u.channel,
  count(*) as no_payment
from users as u
left join payments as p on p.user_id = u.user_id
where p.user_id is null
group by u.channel
order by no_payment desc;
sqlТо же через NOT EXISTS и пользователи без активной подписки: 3 701 и 4 098
select
  (select count(*)
   from users as u
   where not exists (
     select 1 from payments as p where p.user_id = u.user_id
   )) as no_payment,
  (select count(*)
   from users as u
   left join subscriptions as s
     on s.user_id = u.user_id
    and s.status = 'active'
   where s.user_id is null) as no_active_subscription;

COUNT(*) или COUNT(столбец) после LEFT JOIN

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

count(*) считает строки результата. Пользователь без платежей — это одна строка, пользователь с двумя платежами — две. Для organic получается 1 903, и это число не означает ничего полезного: не пользователи и не платежи.

count(p.payment_id) считает строки, где платёж нашёлся, то есть платежи: 566. count(distinct u.user_id) — пользователей: 1 747. count(distinct p.user_id) — плательщиков: 410.

Типичная ошибка — посчитать «число платежей на канал» через count(*). В paid_search вышло бы 1 032 вместо 103: почти все пользователи канала не платили, и каждый добавил в счётчик по единице. Так что, если после LEFT JOIN нужен подсчёт по правой таблице, называйте её столбец явно.

Результат: rows_cnt не равен ни одному осмысленному числу
channelrows_cntpaymentsuserspayers
organic19035661747410
referral11433841037278
paid_search1032103101586
partner874198814138
Четыре счётчика на одном соединении
select
  u.channel,
  count(*) as rows_cnt,
  count(p.payment_id) as payments,
  count(distinct u.user_id) as users,
  count(distinct p.user_id) as payers
from users as u
left join payments as p on p.user_id = u.user_id
group by u.channel
order by users desc;

Почему после LEFT JOIN строк стало больше

LEFT JOIN гарантирует, что строк будет не меньше, чем в левой таблице. Что их будет столько же, он не обещает. Если связь один-ко-многим, левая строка повторяется по числу найденных пар. Пользователь 39 из первого примера занял две строки, потому что у него два платежа. Пока вы считаете count(distinct u.user_id), это безвредно.

Плохо становится, когда к одной левой таблице подряд присоединяют две «многие». Допустим, к пользователям нужны и выручка, и число действий в продукте. Если присоединить payments, а следом events, каждый платёж пользователя повторится по числу его событий. У человека с двумя платежами и тремя действиями получится шесть строк, и его выручка в сумме утроится.

На учебной базе запрос ниже возвращает 8 691 строку и выручку 56 176. Настоящая выручка — 30 639. Ошибки нет, предупреждений нет, число выглядит правдоподобно.

Надёжный способ — сначала свернуть каждую правую таблицу до одной строки на пользователя, а потом присоединять. Тогда связь становится один-к-одному, и размножаться нечему. Исправленный запрос даёт по каналам 14 114, 9 326, 2 467 и 4 732 — в сумме те самые 30 639. Как искать такие раздувания в чужих запросах, разобрано в статье «JOIN без потерь и дублей».

Неверно: два LEFT JOIN один-ко-многим подряд дают выручку 56 176 вместо 30 639
select
  sum(p.amount) as revenue,
  count(*) as rows_cnt
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
 and e.event_name <> 'app_open';
sqlВерно: сначала одна строка на пользователя в каждом CTE, потом LEFT JOIN
with revenue as (
  select user_id, sum(amount) as revenue
  from payments
  group by user_id
),
actions as (
  select user_id, count(*) as actions
  from events
  where event_name <> 'app_open'
  group by user_id
)
select
  u.channel,
  count(*) as users,
  coalesce(sum(r.revenue), 0) as revenue,
  coalesce(sum(a.actions), 0) as actions
from users as u
left join revenue as r on r.user_id = u.user_id
left join actions as a on a.user_id = u.user_id
group by u.channel
order by users desc;

Как соединить несколько таблиц через LEFT JOIN

Соединения выполняются по цепочке слева направо: результат первого JOIN становится левой стороной для второго. Отсюда два следствия.

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

Второе: один INNER JOIN в середине цепочки отменяет LEFT JOIN перед ним. Пример: пользователи, их подписки и платежи по подпискам. Если подписку присоединить через LEFT JOIN, а платежи через обычный JOIN по s.user_id, то у пользователя без подписки s.user_id равен NULL, пары в платежах для него нет, и второй JOIN его выбрасывает. Из 4 613 пользователей остаётся 912. С двумя LEFT JOIN остаются все 4 613.

Поэтому в цепочке после первого LEFT JOIN обычно идут только LEFT JOIN. Если какие-то две таблицы действительно нужно соединить строго, соберите их в CTE и присоединяйте к основной таблице уже готовый результат.

INNER JOIN после LEFT JOIN: из 4 613 пользователей остаётся 912
select
  (select count(distinct u.user_id)
   from users as u
   left join subscriptions as s on s.user_id = u.user_id
   left join payments as p on p.user_id = s.user_id) as left_then_left,
  (select count(distinct u.user_id)
   from users as u
   left join subscriptions as s on s.user_id = u.user_id
   join payments as p on p.user_id = s.user_id) as left_then_inner;

LEFT JOIN и RIGHT JOIN: в чём разница

RIGHT JOIN делает то же самое, только целиком сохраняет правую таблицу. payments right join users и users left join payments возвращают одни и те же 4 952 строки: 1 251 с платежом и 3 701 без.

На практике RIGHT JOIN почти не пишут, и дело в удобстве чтения. Запрос читают сверху вниз, и когда главная таблица стоит в FROM, сразу понятно, чьи строки сохраняются. В цепочке из трёх-четырёх соединений смесь LEFT и RIGHT заставляет каждый раз вычислять, что уцелело. Проще договориться: главная таблица слева, дальше только LEFT JOIN. Встретили RIGHT JOIN в чужом запросе — поменяйте таблицы местами и перепишите на LEFT, результат не изменится.

Слово OUTER необязательное. LEFT OUTER JOIN и LEFT JOIN — одна и та же операция в PostgreSQL, MySQL, SQL Server и DuckDB, выбор только за стилем команды. Третий внешний JOIN, FULL JOIN, сохраняет строки без пары с обеих сторон; он нужен для сверок двух источников и разобран в отдельной статье.

RIGHT JOIN с переставленными таблицами: те же 4 952 строки и 1 251 платёж
select
  count(*) as rows_cnt,
  count(p.payment_id) as payments,
  count(distinct u.user_id) as users
from payments as p
right join users as u on p.user_id = u.user_id;

Частые вопросы про LEFT JOIN

Может ли LEFT JOIN вернуть больше строк, чем в левой таблице?

Да, если для одной левой строки справа нашлось несколько пар. В учебной базе 4 613 пользователей дают 4 952 строки после соединения с платежами. Меньше строк, чем слева, LEFT JOIN сам вернуть не может — только вместе с фильтром в WHERE.

Что быстрее, LEFT JOIN или INNER JOIN?

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

Можно ли писать в ON несколько условий?

Можно, через AND. Всё, что стоит в ON, определяет, какие строки справа считать парой. Левая строка остаётся в результате при любом условии, даже при заведомо ложном.

Куда ставить условие по левой таблице, в ON или в WHERE?

В WHERE. Условие по левой таблице в ON не убирает левые строки: оно только запрещает искать для них пару, и вы получите всех пользователей, а NULL справа будет у тех, кто не прошёл условие. Обычно ждут другого.

Как понять, что LEFT JOIN потерял строки?

Сравните count(distinct ключ) левой таблицы до соединения и в итоговом запросе. Если число уменьшилось, ищите условие по правой таблице в WHERE или INNER JOIN дальше по цепочке.

Нужно ли писать DISTINCT после LEFT JOIN, если строки задублировались?

Сначала выясните, откуда дубли. Чаще всего справа несколько строк на ключ, и правильнее свернуть правую таблицу до соединения, чем убирать повторы после.

Что попробовать на учебной базе

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

  • В запросе про плательщиков августа перенесите условие на дату из ON в WHERE и сравните столбец users: должно получиться 283, 198, 71 и 90 вместо полных размеров каналов.
  • Найдите пользователей, у которых нет ни одного события report_created: LEFT JOIN с events, название события в ON, проверка на NULL в WHERE. Потом перепишите через NOT EXISTS и сверьте числа: должно получиться 2 838.
  • В неверном запросе с двумя LEFT JOIN уберите соединение с events и убедитесь, что выручка вернулась к 30 639. Затем посчитайте её же через CTE из исправленного варианта.
Продолжить чтение
Вся библиотека