INNER против OUTER join
INNER оставляет только совпавшие пары; LEFT/RIGHT/FULL OUTER оставляют и несовпавшие строки, расширяя их NULL. Классический баг прода: предикат на внешней таблице в WHERE молча низводит LEFT JOIN до INNER JOIN.
Отчёт «пользователи, которые ещё не заказывали» тихо показывал ноль строк три недели. Запрос был 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 B≡B 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 Сформировать концептуальное произведение users и orders
- 2 Применить предикат ON, чтобы решить, какие пары являются совпадениями
- 3 NULL-расширить каждую левую строку (пользователя), не нашедшую совпадающего заказа
- 4 Применить фильтр WHERE (u.country = 'JP') к полученным строкам
- 5 Спроецировать колонки SELECT и вернуть строки
- 01Что значит NULL-расширение и какой join его производит?
- 02Объясни, как WHERE o.status = 'x' молча превращает LEFT JOIN в INNER JOIN.
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.