open atlas
↑ К треку
SQL и PostgreSQL вглубь SQL · 02 · 02

INNER против OUTER join

INNER оставляет только совпавшие пары; LEFT/RIGHT/FULL OUTER оставляют и несовпавшие строки, расширяя их NULL. Классический баг прода: предикат на внешней таблице в WHERE молча низводит LEFT JOIN до INNER JOIN.

SQL Middle ◷ 15 min
Уровень
ОсновыJuniorMiddleSenior

Отчёт «пользователи, которые ещё не заказывали» тихо показывал ноль строк три недели. Запрос был LEFT JOIN от users к orders — пока верно — но внизу сидела клауза WHERE o.status = 'cancelled'. Эта одна строка превратила outer join обратно в inner join и стёрла каждого несовпавшего пользователя. Дашборд не сломался; он фильтровал по колонке, которая NULL ровно для тех строк, которые он должен был найти. Это самый частый баг join в проде, и он прячется на виду.

INNER оставляет совпадения; OUTER оставляет и несовпавшие

Из прошлого урока: JOIN — это отфильтрованное произведение, и вопрос в том, что делать со строками, не нашедшими партнёра. Этот выбор и есть различие inner/outer.

  • INNER JOIN — оставить лишь выжившие пары. Пользователь без заказов просто исчезает из результата.
  • LEFT OUTER JOIN — оставить каждую левую строку при любом раскладе. Если левая строка нашла совпадение — она спаривается нормально; если нет — она всё равно выдаётся один раз, со всеми колонками правой стороны в NULL. Это NULL-расширение.
  • RIGHT OUTER JOIN — зеркало: оставить каждую правую строку, NULL-расширить несовпавшую левую. (A RIGHT JOIN BB LEFT JOIN A; большинство команд нормализуют к LEFT ради читаемости.)
  • FULL OUTER JOIN — оставить всё: совпавшие пары, несовпавшие левые строки (правая в NULL) и несовпавшие правые строки (левая в NULL).
-- Каждый пользователь плюс его заказы при наличии; у пользователей без заказов колонки заказа NULL
SELECT u.id, u.name, o.id AS order_id, o.total
FROM users u
LEFT JOIN orders o ON o.user_id = u.id;

Пользователь, который никогда не заказывал, появляется здесь один раз с order_id и total в NULL. Эта строка — вся причина использовать outer join: она несёт информацию «у этой левой строки не было партнёра».

Ловушка: WHERE на внешней таблице низводит join

Вот баг из хука, в дистилляте. Расположение предиката решает, фильтрует ли он произведениеON) или результатWHERE) — а для outer join это не одно и то же.

-- НЕВЕРНО: это тайно INNER JOIN
SELECT u.id, u.name, o.id AS order_id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.status = 'shipped';   -- выполняется ПОСЛЕ NULL-расширения

Для пользователя без заказов o.status — это NULL. Сравнение NULL = 'shipped' даёт UNKNOWN, что WHERE трактует как ложь, поэтому NULL-расширенная строка отбрасывается. Каждая несовпавшая левая строка выбрасывается — LEFT JOIN теперь ведёт себя ровно как INNER JOIN. Фикс — перенести условие в ON, чтобы оно фильтровало, какие заказы считаются совпадением, до NULL-расширения:

-- ВЕРНО: фильтр совпадения в ON, сохраняем семантику outer
SELECT u.id, u.name, o.id AS order_id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'shipped';

Теперь пользователи без отгруженного заказа всё равно выдаются, NULL-расширенными. Правило: предикат на внешней (правой) таблице принадлежит ON, а не WHERE. WHERE на NULLable колонке внешней таблицы — почти всегда низведённый outer join. Единственное законное исключение — WHERE o.id IS NULL, которое намеренно оставляет только несовпавшие строки — это паттерн anti-join из следующего урока.

Чтение и подсчёт результатов outer

Поскольку несовпавшие строки несут NULL, для отображения или агрегации внешние колонки обычно оборачивают в COALESCE:

SELECT u.id,
       COUNT(o.id)                  AS order_count,   -- COUNT игнорирует NULL → 0 для незаказывавших
       COALESCE(SUM(o.total), 0)    AS lifetime_value -- SUM по нулю строк это NULL, а не 0
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id;

Здесь прячутся две сеньорские детали. COUNT(o.id) считает не-NULL значения, поэтому пользователь без заказов корректно получает 0 — а вот COUNT(*) посчитал бы NULL-расширенную строку как 1, классическая ошибка на единицу. И SUM по нулю совпавших строк возвращает NULL, а не 0, поэтому COALESCE(SUM(...), 0) обязателен ради вменяемого итога. Чтобы разделить совпавшие и несовпавшие, считай o.id IS NULL: это твоё число «пользователей, которые никогда не заказывали».

Частая ошибка

Более тонкая версия ловушки: LEFT JOIN orders o ON o.user_id = u.id WHERE o.total > 100. Люди читают это как «пользователи и их заказы больше 100, включая пользователей без такого заказа». Это не так — оно молча выбрасывает каждого пользователя, чьи заказы все меньше 100 (и каждого пользователя без заказов), потому что их NULL или отфильтрованные строки проваливают WHERE. Если ты хочешь «пользователи с их крупными заказами при наличии», условие > 100 должно жить в ON. Мысленный тест: отвергнет ли этот предикат NULL-расширенную строку? Если да и ты хотел её оставить — он принадлежит ON.

Викторина

Тебе нужны все пользователи плюс их заказы, включая пользователей с нулём заказов. Какая клауза оставляет пользователей без заказов?

Викторина

К LEFT JOIN от users к orders добавлен WHERE o.status = 'paid'. Что на самом деле происходит с пользователями, которые никогда не заказывали?

Расставь шаги по порядку

Расставь логические шаги Postgres для SELECT ... FROM users u LEFT JOIN orders o ON o.user_id=u.id WHERE u.country='JP':

  1. 1 Сформировать концептуальное произведение users и orders
  2. 2 Применить предикат ON, чтобы решить, какие пары являются совпадениями
  3. 3 NULL-расширить каждую левую строку (пользователя), не нашедшую совпадающего заказа
  4. 4 Применить фильтр WHERE (u.country = 'JP') к полученным строкам
  5. 5 Спроецировать колонки SELECT и вернуть строки
Вспомните перед уходом
  1. 01
    Что значит NULL-расширение и какой join его производит?
  2. 02
    Объясни, как WHERE o.status = 'x' молча превращает LEFT JOIN в INNER JOIN.
  3. 03
    Зачем COUNT(o.id) и COALESCE(SUM(o.total),0) при агрегации LEFT JOIN по пользователю?
Итог

Различие inner/outer отвечает на «что делать со строками без партнёра». INNER их выбрасывает; LEFT/RIGHT/FULL OUTER оставляют несовпавшие строки и NULL-расширяют отсутствующую сторону, так что пользователь без заказов всё равно появляется один раз с NULL-колонками заказа — это строка, несущая информацию «нет совпадения». Доминирующий баг прода — расположение предиката: условие на внешней (правой) таблице принадлежит ON, потому что WHERE выполняется после NULL-расширения и отвергает NULL-строки (NULL = 'x' это UNKNOWN → ложь), молча низводя LEFT JOIN до INNER JOIN. Мысленный тест: «отвергнет ли этот предикат NULL-расширенную строку, которую я хочу оставить?» — если да, он идёт в ON. Законный WHERE o.id IS NULL — исключение: он намеренно оставляет лишь несовпавшие строки, это anti-join из следующего урока. При агрегации результатов outer используй COUNT(o.id) (учитывающий NULL) и COALESCE(SUM(...),0), чтобы чисто обработать NULL. Теперь, когда видишь LEFT JOIN с последующим WHERE по правой таблице, остановишься и спросишь себя: не отвергает ли это условие строки, которые должны остаться? Если да — ему место в ON.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 8 завершено
Связанные уроки
открывает

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.