Соединение таблиц — навык, без которого не обходится ни один запрос к реальной базе данных. Данные почти никогда не лежат в одной таблице: пользователи отдельно, их заказы отдельно, товары — в третьей таблице. Чтобы собрать их вместе по запросу вроде «покажи имена клиентов и суммы их заказов», нужен JOIN.
В этой статье разберём все типы JOIN в SQL на одном сквозном примере: INNER JOIN, LEFT JOIN, RIGHT JOIN, FULL OUTER JOIN, CROSS JOIN и SELF JOIN. Покажем код, наглядный результат каждого запроса, разницу между LEFT JOIN и INNER JOIN, классическую ловушку с условием в ON против WHERE, соединение нескольких таблиц и частые ошибки. Все запросы и результаты проверены на реальных данных и корректны для PostgreSQL и MySQL — диалектные нюансы оговорены отдельно.
Это часть 3 серии «SQL с нуля» — после основ и агрегатов с подзапросами. Можно читать и отдельно, JOIN не требует знания предыдущих частей наизусть.
В этой статье разберём все типы JOIN в SQL на одном сквозном примере: INNER JOIN, LEFT JOIN, RIGHT JOIN, FULL OUTER JOIN, CROSS JOIN и SELF JOIN. Покажем код, наглядный результат каждого запроса, разницу между LEFT JOIN и INNER JOIN, классическую ловушку с условием в ON против WHERE, соединение нескольких таблиц и частые ошибки. Все запросы и результаты проверены на реальных данных и корректны для PostgreSQL и MySQL — диалектные нюансы оговорены отдельно.
Это часть 3 серии «SQL с нуля» — после основ и агрегатов с подзапросами. Можно читать и отдельно, JOIN не требует знания предыдущих частей наизусть.
Что такое JOIN в SQL и зачем он нужен
JOIN — это операция, которая объединяет строки из двух (или более) таблиц по условию связи. Чаще всего связь — это равенство ключей: первичного ключа одной таблицы и внешнего ключа другой.
Представьте две таблицы. В users хранятся пользователи, в orders — заказы. Каждый заказ ссылается на пользователя через колонку user_id. JOIN позволяет «склеить» заказ с его владельцем в одну строку результата.
Заведём данные, на которых будем работать всю статью.
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT,
city TEXT
);
INSERT INTO users (id, name, city) VALUES
(1, 'Анна', 'Москва'),
(2, 'Борис', 'Казань'),
(3, 'Вера', 'Москва'),
(4, 'Глеб', 'Сочи');
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
user_id INTEGER,
amount INTEGER,
status TEXT
);
INSERT INTO orders (id, user_id, amount, status) VALUES
(101, 1, 1500, 'paid'),
(102, 1, 800, 'paid'),
(103, 2, 2300, 'pending'),
(104, 5, 500, 'paid');
Представьте две таблицы. В users хранятся пользователи, в orders — заказы. Каждый заказ ссылается на пользователя через колонку user_id. JOIN позволяет «склеить» заказ с его владельцем в одну строку результата.
Заведём данные, на которых будем работать всю статью.
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT,
city TEXT
);
INSERT INTO users (id, name, city) VALUES
(1, 'Анна', 'Москва'),
(2, 'Борис', 'Казань'),
(3, 'Вера', 'Москва'),
(4, 'Глеб', 'Сочи');
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
user_id INTEGER,
amount INTEGER,
status TEXT
);
INSERT INTO orders (id, user_id, amount, status) VALUES
(101, 1, 1500, 'paid'),
(102, 1, 800, 'paid'),
(103, 2, 2300, 'pending'),
(104, 5, 500, 'paid');
Обратите внимание на два специально подобранных «крайних случая» — именно на них видна разница между типами JOIN:
— У пользователей Вера (id 3) и Глеб (id 4) нет ни одного заказа.
— Заказ 104 ссылается на user_id = 5, а такого пользователя в таблице users нет (заказ-сирота).
Вот так выглядят наши таблицы:
users
— 1 — name: Анна, city: Москва
— 2 — name: Борис, city: Казань
— 3 — name: Вера, city: Москва
— 4 — name: Глеб, city: Сочи
orders
— 101 — user_id: 1, amount: 1500, status: paid
— 102 — user_id: 1, amount: 800, status: paid
— 103 — user_id: 2, amount: 2300, status: pending
— 104 — user_id: 5, amount: 500, status: paid
Дальше идём по типам соединений от самого частого к редким.
— У пользователей Вера (id 3) и Глеб (id 4) нет ни одного заказа.
— Заказ 104 ссылается на user_id = 5, а такого пользователя в таблице users нет (заказ-сирота).
Вот так выглядят наши таблицы:
users
— 1 — name: Анна, city: Москва
— 2 — name: Борис, city: Казань
— 3 — name: Вера, city: Москва
— 4 — name: Глеб, city: Сочи
orders
— 101 — user_id: 1, amount: 1500, status: paid
— 102 — user_id: 1, amount: 800, status: paid
— 103 — user_id: 2, amount: 2300, status: pending
— 104 — user_id: 5, amount: 500, status: paid
Дальше идём по типам соединений от самого частого к редким.
Что такое INNER JOIN и что он возвращает?
INNER JOIN возвращает только те строки, для которых нашлась пара в обеих таблицах. Если у пользователя нет заказов или у заказа нет пользователя — такая строка в результат не попадёт.
SELECT u.name, o.id AS order_id, o.amount
FROM users u
INNER JOIN orders o ON u.id = o.user_id
ORDER BY o.id;
SELECT u.name, o.id AS order_id, o.amount
FROM users u
INNER JOIN orders o ON u.id = o.user_id
ORDER BY o.id;
Результат:
— Анна — order_id: 101, amount: 1500
— Анна — order_id: 102, amount: 800
— Борис — order_id: 103, amount: 2300
Что произошло:
— Анна (id 1) совпала с двумя своими заказами — попала дважды.
— Борис (id 2) совпал с одним заказом.
— Вера и Глеб не попали — у них нет заказов.
— Заказ 104 (user_id = 5) не попал — нет такого пользователя.
Словами через диаграмму Венна: INNER JOIN — это пересечение двух кругов, общая середина.
Ключевое слово INNER можно опускать: JOIN без уточнения в SQL означает именно INNER JOIN. Это работает одинаково в PostgreSQL и MySQL.
Есть и сокращённые формы записи условия. Когда колонки-ключи в обеих таблицах названы одинаково, вместо ON a.x = b.x можно писать USING (x). А NATURAL JOIN вообще соединяет по всем одноимённым колонкам автоматически, без явного условия. На практике NATURAL JOIN не рекомендуют: набор колонок для соединения становится неявным и молча меняется при любой правке схемы — поэтому почти всегда пишут явный ON (или USING).
— Анна — order_id: 101, amount: 1500
— Анна — order_id: 102, amount: 800
— Борис — order_id: 103, amount: 2300
Что произошло:
— Анна (id 1) совпала с двумя своими заказами — попала дважды.
— Борис (id 2) совпал с одним заказом.
— Вера и Глеб не попали — у них нет заказов.
— Заказ 104 (user_id = 5) не попал — нет такого пользователя.
Словами через диаграмму Венна: INNER JOIN — это пересечение двух кругов, общая середина.
Ключевое слово INNER можно опускать: JOIN без уточнения в SQL означает именно INNER JOIN. Это работает одинаково в PostgreSQL и MySQL.
Есть и сокращённые формы записи условия. Когда колонки-ключи в обеих таблицах названы одинаково, вместо ON a.x = b.x можно писать USING (x). А NATURAL JOIN вообще соединяет по всем одноимённым колонкам автоматически, без явного условия. На практике NATURAL JOIN не рекомендуют: набор колонок для соединения становится неявным и молча меняется при любой правке схемы — поэтому почти всегда пишут явный ON (или USING).
Что такое LEFT JOIN и чем он отличается от INNER JOIN?
LEFT JOIN (полное имя — LEFT OUTER JOIN, слово OUTER необязательно) возвращает все строки левой таблицы. Если пары в правой таблице нет, колонки правой таблицы заполняются значением NULL.
«Левая» таблица — та, что стоит после FROM. В нашем запросе это users.
SELECT u.name, o.id AS order_id, o.amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
ORDER BY u.id, o.id;
«Левая» таблица — та, что стоит после FROM. В нашем запросе это users.
SELECT u.name, o.id AS order_id, o.amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
ORDER BY u.id, o.id;
Результат:
— Анна — order_id: 101, amount: 1500
— Анна — order_id: 102, amount: 800
— Борис — order_id: 103, amount: 2300
— Вера — order_id: NULL, amount: NULL
— Глеб — order_id: NULL, amount: NULL
Теперь Вера и Глеб есть в результате, хотя заказов у них нет — в колонках заказа стоит NULL. А вот заказ-сирота 104 по-прежнему отсутствует: он относится к правой таблице, а её «лишние» строки LEFT JOIN не тянет.
Это и есть ответ на популярный запрос «left join inner join разница»: INNER JOIN оставляет только пересечение, LEFT JOIN дополнительно сохраняет все строки левой таблицы, подставляя NULL там, где пары не нашлось.
Как найти строки без пары через LEFT JOIN (anti-join)?
Очень частый практический приём: найти строки левой таблицы, у которых нет соответствия в правой. Например, пользователи без единого заказа. Делается это связкой LEFT JOIN и фильтра WHERE <ключ правой таблицы> IS NULL.
SELECT u.name
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL
ORDER BY u.id;
— Анна — order_id: 101, amount: 1500
— Анна — order_id: 102, amount: 800
— Борис — order_id: 103, amount: 2300
— Вера — order_id: NULL, amount: NULL
— Глеб — order_id: NULL, amount: NULL
Теперь Вера и Глеб есть в результате, хотя заказов у них нет — в колонках заказа стоит NULL. А вот заказ-сирота 104 по-прежнему отсутствует: он относится к правой таблице, а её «лишние» строки LEFT JOIN не тянет.
Это и есть ответ на популярный запрос «left join inner join разница»: INNER JOIN оставляет только пересечение, LEFT JOIN дополнительно сохраняет все строки левой таблицы, подставляя NULL там, где пары не нашлось.
Как найти строки без пары через LEFT JOIN (anti-join)?
Очень частый практический приём: найти строки левой таблицы, у которых нет соответствия в правой. Например, пользователи без единого заказа. Делается это связкой LEFT JOIN и фильтра WHERE <ключ правой таблицы> IS NULL.
SELECT u.name
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL
ORDER BY u.id;
Результат:
— Вера
— Глеб
Логика: после LEFT JOIN у строк без пары все колонки orders равны NULL. Фильтр o.id IS NULL оставляет ровно такие строки. Этот паттерн называют anti-join. Важно проверять IS NULL именно по той колонке правой таблицы, которая гарантированно не бывает пустой для настоящих строк, — обычно это первичный ключ (o.id).
— Вера
— Глеб
Логика: после LEFT JOIN у строк без пары все колонки orders равны NULL. Фильтр o.id IS NULL оставляет ровно такие строки. Этот паттерн называют anti-join. Важно проверять IS NULL именно по той колонке правой таблицы, которая гарантированно не бывает пустой для настоящих строк, — обычно это первичный ключ (o.id).
Что такое RIGHT JOIN и когда его использовать?
RIGHT JOIN (он же RIGHT OUTER JOIN) — зеркало LEFT JOIN. Он возвращает все строки правой таблицы, подставляя NULL слева там, где пары не нашлось.
SELECT u.name, o.id AS order_id, o.amount
FROM users u
RIGHT JOIN orders o ON u.id = o.user_id
ORDER BY o.id;
SELECT u.name, o.id AS order_id, o.amount
FROM users u
RIGHT JOIN orders o ON u.id = o.user_id
ORDER BY o.id;
Результат:
— Анна — order_id: 101, amount: 1500
— Анна — order_id: 102, amount: 800
— Борис — order_id: 103, amount: 2300
— NULL — order_id: 104, amount: 500
Теперь в результат попал заказ-сирота 104 — у него нет владельца, поэтому name равно NULL. А Вера и Глеб исчезли: они в левой таблице, а её «лишние» строки RIGHT JOIN отбрасывает.
На практике RIGHT JOIN используют редко: почти любой RIGHT JOIN можно переписать как LEFT JOIN, просто поменяв таблицы местами. Запрос A RIGHT JOIN B эквивалентен B LEFT JOIN A. RIGHT JOIN обычно переписывают как LEFT JOIN — так проще держать одно направление чтения.
> Диалектная заметка: RIGHT JOIN поддерживают и PostgreSQL, и MySQL.
— Анна — order_id: 101, amount: 1500
— Анна — order_id: 102, amount: 800
— Борис — order_id: 103, amount: 2300
— NULL — order_id: 104, amount: 500
Теперь в результат попал заказ-сирота 104 — у него нет владельца, поэтому name равно NULL. А Вера и Глеб исчезли: они в левой таблице, а её «лишние» строки RIGHT JOIN отбрасывает.
На практике RIGHT JOIN используют редко: почти любой RIGHT JOIN можно переписать как LEFT JOIN, просто поменяв таблицы местами. Запрос A RIGHT JOIN B эквивалентен B LEFT JOIN A. RIGHT JOIN обычно переписывают как LEFT JOIN — так проще держать одно направление чтения.
> Диалектная заметка: RIGHT JOIN поддерживают и PostgreSQL, и MySQL.
Что такое FULL OUTER JOIN в SQL?
FULL OUTER JOIN (слово OUTER необязательно) возвращает все строки обеих таблиц. Где пара есть — строки склеиваются, где нет — недостающая сторона заполняется NULL. По диаграмме Венна это объединение обоих кругов целиком.
SELECT u.name, o.id AS order_id, o.amount
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id
ORDER BY u.id, o.id;
SELECT u.name, o.id AS order_id, o.amount
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id
ORDER BY u.id, o.id;
Результат:
— Анна — order_id: 101, amount: 1500
— Анна — order_id: 102, amount: 800
— Борис — order_id: 103, amount: 2300
— Вера — order_id: NULL, amount: NULL
— Глеб — order_id: NULL, amount: NULL
— NULL — order_id: 104, amount: 500
Здесь видно сразу всё: совпадения (Анна, Борис), пользователи без заказов (Вера, Глеб) и заказ без пользователя (104). FULL OUTER JOIN — это LEFT JOIN и RIGHT JOIN одновременно.
Как сделать FULL OUTER JOIN в MySQL
Важный диалектный нюанс: в MySQL нет ключевого слова `FULL JOIN` / `FULL OUTER JOIN`. Попытка его написать выдаст синтаксическую ошибку. В PostgreSQL он работает из коробки, а в MySQL его эмулируют через объединение LEFT JOIN и RIGHT JOIN оператором UNION:
-- Рабочий вариант для MySQL
SELECT u.name, o.id AS order_id, o.amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
UNION
SELECT u.name, o.id AS order_id, o.amount
FROM users u
RIGHT JOIN orders o ON u.id = o.user_id;
— Анна — order_id: 101, amount: 1500
— Анна — order_id: 102, amount: 800
— Борис — order_id: 103, amount: 2300
— Вера — order_id: NULL, amount: NULL
— Глеб — order_id: NULL, amount: NULL
— NULL — order_id: 104, amount: 500
Здесь видно сразу всё: совпадения (Анна, Борис), пользователи без заказов (Вера, Глеб) и заказ без пользователя (104). FULL OUTER JOIN — это LEFT JOIN и RIGHT JOIN одновременно.
Как сделать FULL OUTER JOIN в MySQL
Важный диалектный нюанс: в MySQL нет ключевого слова `FULL JOIN` / `FULL OUTER JOIN`. Попытка его написать выдаст синтаксическую ошибку. В PostgreSQL он работает из коробки, а в MySQL его эмулируют через объединение LEFT JOIN и RIGHT JOIN оператором UNION:
-- Рабочий вариант для MySQL
SELECT u.name, o.id AS order_id, o.amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
UNION
SELECT u.name, o.id AS order_id, o.amount
FROM users u
RIGHT JOIN orders o ON u.id = o.user_id;
UNION (без ALL) убирает дубликаты строк, которые попадают в оба запроса (совпавшие пары), поэтому результат совпадает с честным FULL OUTER JOIN. Если в данных возможны полностью одинаковые строки, которые надо сохранить, схему усложняют, но для типового случая «связь по ключу» этого варианта достаточно.
Что такое CROSS JOIN и когда он нужен?
CROSS JOIN соединяет каждую строку левой таблицы с каждой строкой правой. Условия ON у него нет. Если в users 4 строки, а в orders — 4, на выходе будет 4 × 4 = 16 строк.
SELECT u.name, o.id AS order_id
FROM users CROSS JOIN orders;
-- вернёт 16 строк (4 × 4)
SELECT u.name, o.id AS order_id
FROM users CROSS JOIN orders;
-- вернёт 16 строк (4 × 4)
Что произошло: у users 4 строки, у orders 4 строки, CROSS JOIN не фильтрует ничего и просто перемножил все комбинации — 16 строк без единого условия связи.
CROSS JOIN редко нужен осознанно. Реальные сценарии — генерация всех возможных комбинаций: например, «каждый размер × каждый цвет» для каталога товаров или построение календарной сетки «каждый день × каждый сотрудник».
Гораздо чаще декартово произведение получается случайно — когда забывают условие соединения (об этом ниже, в разделе про ошибки). Поэтому, если вы видите, что результат внезапно раздулся до тысяч строк, первым делом проверьте, не превратился ли ваш JOIN в CROSS JOIN.
CROSS JOIN редко нужен осознанно. Реальные сценарии — генерация всех возможных комбинаций: например, «каждый размер × каждый цвет» для каталога товаров или построение календарной сетки «каждый день × каждый сотрудник».
Гораздо чаще декартово произведение получается случайно — когда забывают условие соединения (об этом ниже, в разделе про ошибки). Поэтому, если вы видите, что результат внезапно раздулся до тысяч строк, первым делом проверьте, не превратился ли ваш JOIN в CROSS JOIN.
Что такое SELF JOIN и как его написать?
SELF JOIN — это не отдельный тип, а приём: таблицу соединяют с ней же, используя два разных псевдонима (алиаса). Классический случай — иерархия, когда строки ссылаются друг на друга внутри одной таблицы. Например, сотрудники и их руководители.
CREATE TABLE employees (
id INTEGER PRIMARY KEY,
name TEXT,
manager_id INTEGER -- ссылается на employees.id
);
INSERT INTO employees (id, name, manager_id) VALUES
(1, 'Ирина', NULL), -- руководитель верхнего уровня
(2, 'Олег', 1),
(3, 'Пётр', 1),
(4, 'Рита', 2);
CREATE TABLE employees (
id INTEGER PRIMARY KEY,
name TEXT,
manager_id INTEGER -- ссылается на employees.id
);
INSERT INTO employees (id, name, manager_id) VALUES
(1, 'Ирина', NULL), -- руководитель верхнего уровня
(2, 'Олег', 1),
(3, 'Пётр', 1),
(4, 'Рита', 2);
Чтобы вывести каждого сотрудника рядом с именем его руководителя, соединяем employees саму с собой: алиас e — сотрудник, алиас m — его менеджер.
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id
ORDER BY e.id;
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id
ORDER BY e.id;
Результат:
— Ирина — manager: NULL
— Олег — manager: Ирина
— Пётр — manager: Ирина
— Рита — manager: Олег
Здесь важен именно LEFT JOIN, а не INNER JOIN: у Ирины manager_id равен NULL, и при INNER JOIN она бы выпала из результата. LEFT JOIN сохраняет её, показывая NULL в колонке руководителя. Алиасы обязательны — без них база не поймёт, какую «копию» таблицы вы имеете в виду в каждом месте запроса.
— Ирина — manager: NULL
— Олег — manager: Ирина
— Пётр — manager: Ирина
— Рита — manager: Олег
Здесь важен именно LEFT JOIN, а не INNER JOIN: у Ирины manager_id равен NULL, и при INNER JOIN она бы выпала из результата. LEFT JOIN сохраняет её, показывая NULL в колонке руководителя. Алиасы обязательны — без них база не поймёт, какую «копию» таблицы вы имеете в виду в каждом месте запроса.
Почему LEFT JOIN превращается в INNER JOIN? Разница между ON и WHERE
Это самая частая ошибка с LEFT JOIN, и её любят спрашивать на собеседованиях. Суть: фильтр по колонке правой таблицы, помещённый в `WHERE`, превращает `LEFT JOIN` в `INNER JOIN`.
Допустим, мы хотим вывести всех пользователей и их оплаченные заказы (status = 'paid'), но сохранить и тех, у кого оплаченных заказов нет. Наивный вариант:
-- ЛОВУШКА: фильтр в WHERE
SELECT u.name, o.id, o.status
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.status = 'paid'
ORDER BY u.id;
Допустим, мы хотим вывести всех пользователей и их оплаченные заказы (status = 'paid'), но сохранить и тех, у кого оплаченных заказов нет. Наивный вариант:
-- ЛОВУШКА: фильтр в WHERE
SELECT u.name, o.id, o.status
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.status = 'paid'
ORDER BY u.id;
Результат:
— Анна — id: 101, status: paid
— Анна — id: 102, status: paid
Куда пропали Борис, Вера и Глеб? Дело в порядке выполнения. Сначала LEFT JOIN строит строки, подставляя NULL тем, у кого пары нет. Потом WHERE o.status = 'paid' отсекает все строки, где status не равен 'paid' — а у строк с NULL (Вера, Глеб) и у строки Бориса с pending условие не выполняется. NULL не проходит сравнение = 'paid'. В итоге остались только реально оплаченные заказы — это поведение обычного INNER JOIN.
Правильное решение — перенести условие по правой таблице в ON:
-- ВЕРНО: фильтр в ON
SELECT u.name, o.id, o.status
FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'paid'
ORDER BY u.id;
— Анна — id: 101, status: paid
— Анна — id: 102, status: paid
Куда пропали Борис, Вера и Глеб? Дело в порядке выполнения. Сначала LEFT JOIN строит строки, подставляя NULL тем, у кого пары нет. Потом WHERE o.status = 'paid' отсекает все строки, где status не равен 'paid' — а у строк с NULL (Вера, Глеб) и у строки Бориса с pending условие не выполняется. NULL не проходит сравнение = 'paid'. В итоге остались только реально оплаченные заказы — это поведение обычного INNER JOIN.
Правильное решение — перенести условие по правой таблице в ON:
-- ВЕРНО: фильтр в ON
SELECT u.name, o.id, o.status
FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'paid'
ORDER BY u.id;
Результат:
— Анна — id: 101, status: paid
— Анна — id: 102, status: paid
— Борис — id: NULL, status: NULL
— Вера — id: NULL, status: NULL
— Глеб — id: NULL, status: NULL
Теперь условие status = 'paid' применяется на этапе соединения: к Борису не подцепился его pending-заказ (пары не нашлось — NULL), а Вера и Глеб остались, как и положено при LEFT JOIN.
Правило простое:
— Условие в `ON` — управляет тем, как строки соединяются, и сохраняет «несовпавшие» строки левой таблицы.
— Условие в `WHERE` — фильтрует уже готовый результат и выкидывает строки с NULL.
Для INNER JOIN разницы между ON и WHERE по итогу нет — там нет NULL-строк, которые можно потерять. Ловушка касается именно внешних соединений (LEFT / RIGHT / FULL).
Ещё один нюанс самого условия ON: соединение идёт по равенству, а NULL не равен ничему, даже другому NULL. Если ключ соединения у части строк равен NULL, такие строки между собой не сольются — ON a.key = b.key для них даёт UNKNOWN, а не совпадение. Поэтому соединять таблицы по колонке, в которой бывает NULL, нужно осознанно: пары по NULL-ключу просто не возникнет.
— Анна — id: 101, status: paid
— Анна — id: 102, status: paid
— Борис — id: NULL, status: NULL
— Вера — id: NULL, status: NULL
— Глеб — id: NULL, status: NULL
Теперь условие status = 'paid' применяется на этапе соединения: к Борису не подцепился его pending-заказ (пары не нашлось — NULL), а Вера и Глеб остались, как и положено при LEFT JOIN.
Правило простое:
— Условие в `ON` — управляет тем, как строки соединяются, и сохраняет «несовпавшие» строки левой таблицы.
— Условие в `WHERE` — фильтрует уже готовый результат и выкидывает строки с NULL.
Для INNER JOIN разницы между ON и WHERE по итогу нет — там нет NULL-строк, которые можно потерять. Ловушка касается именно внешних соединений (LEFT / RIGHT / FULL).
Ещё один нюанс самого условия ON: соединение идёт по равенству, а NULL не равен ничему, даже другому NULL. Если ключ соединения у части строк равен NULL, такие строки между собой не сольются — ON a.key = b.key для них даёт UNKNOWN, а не совпадение. Поэтому соединять таблицы по колонке, в которой бывает NULL, нужно осознанно: пары по NULL-ключу просто не возникнет.
Как соединить больше двух таблиц через JOIN?
JOIN-ы выстраивают в цепочку: к результату первого соединения присоединяют следующую таблицу, и так далее. Добавим таблицы товаров и позиций заказа.
CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT
);
INSERT INTO products (id, name) VALUES
(1, 'Курс'), (2, 'Книга'), (3, 'Подписка');
CREATE TABLE order_items (
order_id INTEGER,
product_id INTEGER
);
INSERT INTO order_items (order_id, product_id) VALUES
(101, 1), (101, 3), (102, 2), (103, 1);
CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT
);
INSERT INTO products (id, name) VALUES
(1, 'Курс'), (2, 'Книга'), (3, 'Подписка');
CREATE TABLE order_items (
order_id INTEGER,
product_id INTEGER
);
INSERT INTO order_items (order_id, product_id) VALUES
(101, 1), (101, 3), (102, 2), (103, 1);
Теперь соберём цепочку «пользователь → заказ → позиция → товар»:
SELECT u.name, o.id AS order_id, p.name AS product
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
ORDER BY o.id, p.name;
SELECT u.name, o.id AS order_id, p.name AS product
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
ORDER BY o.id, p.name;
Результат:
— Анна — order_id: 101, product: Курс
— Анна — order_id: 101, product: Подписка
— Анна — order_id: 102, product: Книга
— Борис — order_id: 103, product: Курс
Читается сверху вниз: берём пользователей, цепляем к ним заказы, к заказам — их позиции, к позициям — названия товаров. Каждый JOIN добавляет своё условие ON. Типы соединений в цепочке можно смешивать: где-то INNER, где-то LEFT — в зависимости от того, нужно ли сохранять строки без пары на конкретном шаге.
— Анна — order_id: 101, product: Курс
— Анна — order_id: 101, product: Подписка
— Анна — order_id: 102, product: Книга
— Борис — order_id: 103, product: Курс
Читается сверху вниз: берём пользователей, цепляем к ним заказы, к заказам — их позиции, к позициям — названия товаров. Каждый JOIN добавляет своё условие ON. Типы соединений в цепочке можно смешивать: где-то INNER, где-то LEFT — в зависимости от того, нужно ли сохранять строки без пары на конкретном шаге.
Какие ошибки чаще всего допускают с JOIN?
Почему JOIN дублирует строки?
Когда одной строке левой таблицы соответствует несколько строк правой, она «размножается» в результате. У Анны два заказа — значит, в INNER JOIN она встретится дважды. Само по себе это не ошибка, но становится ловушкой при агрегации.
SELECT u.name, COUNT(*) AS rows_returned
FROM users u
JOIN orders o ON u.id = o.user_id
GROUP BY u.name;
Когда одной строке левой таблицы соответствует несколько строк правой, она «размножается» в результате. У Анны два заказа — значит, в INNER JOIN она встретится дважды. Само по себе это не ошибка, но становится ловушкой при агрегации.
SELECT u.name, COUNT(*) AS rows_returned
FROM users u
JOIN orders o ON u.id = o.user_id
GROUP BY u.name;
— Анна — rows_returned: 2
— Борис — rows_returned: 1
Если бы мы соединили users ещё и с order_items (а у заказа 101 две позиции), число строк выросло бы ещё сильнее, и наивный SUM(o.amount) посчитал бы сумму заказа несколько раз. Правильный подход к подсчётам — группировать по ключу пользователя и аккуратно выбирать, что именно агрегировать:
SELECT u.name, COUNT(o.id) AS orders_cnt, SUM(o.amount) AS total
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name
ORDER BY u.id;
— Борис — rows_returned: 1
Если бы мы соединили users ещё и с order_items (а у заказа 101 две позиции), число строк выросло бы ещё сильнее, и наивный SUM(o.amount) посчитал бы сумму заказа несколько раз. Правильный подход к подсчётам — группировать по ключу пользователя и аккуратно выбирать, что именно агрегировать:
SELECT u.name, COUNT(o.id) AS orders_cnt, SUM(o.amount) AS total
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name
ORDER BY u.id;
— Анна — orders_cnt: 2, total: 2300
— Борис — orders_cnt: 1, total: 2300
— Вера — orders_cnt: 0, total: NULL
— Глеб — orders_cnt: 0, total: NULL
Здесь LEFT JOIN сохранил Веру и Глеба, а COUNT(o.id) корректно показал у них 0 (COUNT не считает NULL, в отличие от COUNT(*)). Сумма у них NULL — при необходимости её заворачивают в COALESCE(SUM(o.amount), 0).
Что будет, если забыть условие JOIN (ON)?
Если соединить таблицы без ON (или с ON, который ничего не ограничивает), получится CROSS JOIN: каждая строка слева умножится на каждую справа. На маленьких таблицах это незаметно, а на двух таблицах по миллиону строк запрос сгенерирует огромное число строк — он либо отвалится по таймауту, либо надолго займёт ресурсы сервера. Всегда проверяйте, что у каждого соединения есть осмысленное условие связи по ключам.
Почему SQL выдаёт ошибку ambiguous column?
Если в обеих таблицах есть колонка с одинаковым именем (например, id), и вы напишете SELECT id без префикса, база выдаст ошибку «ambiguous column». Лечится псевдонимами таблиц и явным указанием u.id, o.id — как мы делали во всех примерах.
— Борис — orders_cnt: 1, total: 2300
— Вера — orders_cnt: 0, total: NULL
— Глеб — orders_cnt: 0, total: NULL
Здесь LEFT JOIN сохранил Веру и Глеба, а COUNT(o.id) корректно показал у них 0 (COUNT не считает NULL, в отличие от COUNT(*)). Сумма у них NULL — при необходимости её заворачивают в COALESCE(SUM(o.amount), 0).
Что будет, если забыть условие JOIN (ON)?
Если соединить таблицы без ON (или с ON, который ничего не ограничивает), получится CROSS JOIN: каждая строка слева умножится на каждую справа. На маленьких таблицах это незаметно, а на двух таблицах по миллиону строк запрос сгенерирует огромное число строк — он либо отвалится по таймауту, либо надолго займёт ресурсы сервера. Всегда проверяйте, что у каждого соединения есть осмысленное условие связи по ключам.
Почему SQL выдаёт ошибку ambiguous column?
Если в обеих таблицах есть колонка с одинаковым именем (например, id), и вы напишете SELECT id без префикса, база выдаст ошибку «ambiguous column». Лечится псевдонимами таблиц и явным указанием u.id, o.id — как мы делали во всех примерах.
JOIN или подзапрос: что выбрать
Часть задач можно решить и через JOIN, и через подзапрос. Например, «пользователи, у которых есть хотя бы один заказ» пишут двумя способами:
-- Через JOIN (с DISTINCT, чтобы убрать дубликаты)
SELECT DISTINCT u.name
FROM users u
JOIN orders o ON u.id = o.user_id;
-- Через подзапрос
SELECT u.name
FROM users u
WHERE u.id IN (SELECT user_id FROM orders);
-- Через JOIN (с DISTINCT, чтобы убрать дубликаты)
SELECT DISTINCT u.name
FROM users u
JOIN orders o ON u.id = o.user_id;
-- Через подзапрос
SELECT u.name
FROM users u
WHERE u.id IN (SELECT user_id FROM orders);
Оба вернут Анну и Бориса. Ориентиры по выбору:
— `JOIN` нужен, когда требуются колонки из обеих таблиц в результате (имя пользователя и сумма его заказа). Подзапрос в WHERE колонок второй таблицы наружу не отдаёт.
— Проверку «существует / не существует» (есть ли заказы, нет ли заказов) часто чище и понятнее выразить через EXISTS / NOT EXISTS или IN / NOT IN, чем через JOIN с DISTINCT или anti-join. Особенно NOT EXISTS безопаснее NOT IN, если в подзапросе могут встретиться NULL.
— По скорости в современных PostgreSQL и MySQL планировщик нередко приводит эквивалентные JOIN и подзапрос к одному плану выполнения. Сначала выбирайте читаемость, оптимизируйте — по факту замеров на своих данных и индексах.
— `JOIN` нужен, когда требуются колонки из обеих таблиц в результате (имя пользователя и сумма его заказа). Подзапрос в WHERE колонок второй таблицы наружу не отдаёт.
— Проверку «существует / не существует» (есть ли заказы, нет ли заказов) часто чище и понятнее выразить через EXISTS / NOT EXISTS или IN / NOT IN, чем через JOIN с DISTINCT или anti-join. Особенно NOT EXISTS безопаснее NOT IN, если в подзапросе могут встретиться NULL.
— По скорости в современных PostgreSQL и MySQL планировщик нередко приводит эквивалентные JOIN и подзапрос к одному плану выполнения. Сначала выбирайте читаемость, оптимизируйте — по факту замеров на своих данных и индексах.
Частые вопросы про JOIN в SQL
В чём разница между LEFT JOIN и INNER JOIN? INNER JOIN возвращает только строки, для которых нашлась пара в обеих таблицах. LEFT JOIN дополнительно сохраняет все строки левой таблицы, подставляя NULL в колонки правой там, где пары не нашлось.
Чем JOIN отличается от UNION? Это разные операции. JOIN соединяет таблицы «по горизонтали» — добавляет колонки из второй таблицы к строкам первой по условию связи. UNION объединяет результаты «по вертикали» — складывает строки двух запросов с одинаковым набором колонок в одну выборку, убирая дубликаты (UNION ALL дубликаты не убирает). Из этой статьи UNION использовался как раз не вместо JOIN, а вместе с ним — для эмуляции FULL OUTER JOIN в MySQL.
Можно ли использовать JOIN без условия ON? Можно, но это превращает соединение в CROSS JOIN — декартово произведение, каждая строка левой таблицы со каждой строкой правой. На больших таблицах это случайная и опасная ошибка, а не осознанный приём.
Что быстрее — JOIN или подзапрос? Часто одинаково: современные PostgreSQL и MySQL приводят эквивалентные JOIN и подзапрос к одному и тому же плану выполнения. Разница в скорости, если она есть, зависит от конкретных данных и индексов — сначала стоит выбирать то, что читаемее, а не гадать заранее.
Сколько типов JOIN в SQL? Шесть основных: INNER JOIN, LEFT JOIN, RIGHT JOIN, FULL OUTER JOIN, CROSS JOIN и SELF JOIN (последний — не отдельный синтаксис, а приём соединения таблицы самой с собой).
Чем JOIN отличается от UNION? Это разные операции. JOIN соединяет таблицы «по горизонтали» — добавляет колонки из второй таблицы к строкам первой по условию связи. UNION объединяет результаты «по вертикали» — складывает строки двух запросов с одинаковым набором колонок в одну выборку, убирая дубликаты (UNION ALL дубликаты не убирает). Из этой статьи UNION использовался как раз не вместо JOIN, а вместе с ним — для эмуляции FULL OUTER JOIN в MySQL.
Можно ли использовать JOIN без условия ON? Можно, но это превращает соединение в CROSS JOIN — декартово произведение, каждая строка левой таблицы со каждой строкой правой. На больших таблицах это случайная и опасная ошибка, а не осознанный приём.
Что быстрее — JOIN или подзапрос? Часто одинаково: современные PostgreSQL и MySQL приводят эквивалентные JOIN и подзапрос к одному и тому же плану выполнения. Разница в скорости, если она есть, зависит от конкретных данных и индексов — сначала стоит выбирать то, что читаемее, а не гадать заранее.
Сколько типов JOIN в SQL? Шесть основных: INNER JOIN, LEFT JOIN, RIGHT JOIN, FULL OUTER JOIN, CROSS JOIN и SELF JOIN (последний — не отдельный синтаксис, а приём соединения таблицы самой с собой).
Шпаргалка по типам JOIN
— `INNER JOIN` — Что возвращает: только совпавшие пары, Где есть NULL: нет
— `LEFT JOIN` — Что возвращает: все строки левой + совпадения справа, Где есть NULL: справа, где нет пары
— `RIGHT JOIN` — Что возвращает: все строки правой + совпадения слева, Где есть NULL: слева, где нет пары
— `FULL OUTER JOIN` — Что возвращает: все строки обеих таблиц, Где есть NULL: с обеих сторон
— `CROSS JOIN` — Что возвращает: каждая строка × каждая (без ON), Где есть NULL: нет
— `SELF JOIN` — Что возвращает: таблица соединяется сама с собой, Где есть NULL: зависит от типа
Запомнить разницу проще через картинку: INNER — пересечение кругов, LEFT — весь левый круг, RIGHT — весь правый, FULL — оба круга целиком, а anti-join (LEFT JOIN ... IS NULL) — левый круг без пересечения.
— `LEFT JOIN` — Что возвращает: все строки левой + совпадения справа, Где есть NULL: справа, где нет пары
— `RIGHT JOIN` — Что возвращает: все строки правой + совпадения слева, Где есть NULL: слева, где нет пары
— `FULL OUTER JOIN` — Что возвращает: все строки обеих таблиц, Где есть NULL: с обеих сторон
— `CROSS JOIN` — Что возвращает: каждая строка × каждая (без ON), Где есть NULL: нет
— `SELF JOIN` — Что возвращает: таблица соединяется сама с собой, Где есть NULL: зависит от типа
Запомнить разницу проще через картинку: INNER — пересечение кругов, LEFT — весь левый круг, RIGHT — весь правый, FULL — оба круга целиком, а anti-join (LEFT JOIN ... IS NULL) — левый круг без пересечения.
Закрепить JOIN на практике
Теорию по соединениям читать полезно, но навык приходит только от написания запросов руками: пока сами не словите превращение LEFT JOIN в INNER JOIN или дубликаты из связи один-ко-многим, в голове это не осядет.
Отработать все типы JOIN от простых к сложным можно на онлайн-тренажёре SQL Arena от Quality Academy. Целый блок задач посвящён именно соединениям — от первого INNER JOIN до многотабличных запросов и каверзных случаев с NULL и фильтрами в ON против WHERE, часть — по мотивам вопросов с собеседований в Яндекс, Т-Банк, Сбер, Ozon, VK и Авито. Запросы пишутся прямо в браузере, а AI-ментор подсказывает, если запрос не сходится. Диалект можно переключить — PostgreSQL, MySQL или ClickHouse, — что удобно, раз в этой статье мы отдельно разбирали, как в MySQL приходится эмулировать FULL OUTER JOIN через UNION. Более 800 задач, значительная часть доступна бесплатно.
Отработать все типы JOIN от простых к сложным можно на онлайн-тренажёре SQL Arena от Quality Academy. Целый блок задач посвящён именно соединениям — от первого INNER JOIN до многотабличных запросов и каверзных случаев с NULL и фильтрами в ON против WHERE, часть — по мотивам вопросов с собеседований в Яндекс, Т-Банк, Сбер, Ozon, VK и Авито. Запросы пишутся прямо в браузере, а AI-ментор подсказывает, если запрос не сходится. Диалект можно переключить — PostgreSQL, MySQL или ClickHouse, — что удобно, раз в этой статье мы отдельно разбирали, как в MySQL приходится эмулировать FULL OUTER JOIN через UNION. Более 800 задач, значительная часть доступна бесплатно.
-------
Полезные ссылки школы
Сайт Quality Academy:
/main
Telegram-канал:
https://t.me/quality_academy
YouTube-канал:
https://www.youtube.com/@quality_academy
ВКонтакте:
https://vk.com/quality_academy
Канал отзывов учеников (58+ отзывов):
https://t.me/quality_academy_reviews
Задать вопрос менеджеру:
https://t.me/quality_academy_bot
Тест «Подойдёт ли вам тестирование»:
https://quiz.quality-academy.ru
5000+ вопросов с собесов тестировщика — бот-тренажёр:
https://t.me/quality_academy_interview_bot
3 практические задачи и дорожная карта:
https://t.me/quality_academy_tasks_bot
Бесплатные тренажёры — SQL Arena, Playwright Arena, Python Arena:
SQL Arena · Playwright Arena · Python Arena
/main
Telegram-канал:
https://t.me/quality_academy
YouTube-канал:
https://www.youtube.com/@quality_academy
ВКонтакте:
https://vk.com/quality_academy
Канал отзывов учеников (58+ отзывов):
https://t.me/quality_academy_reviews
Задать вопрос менеджеру:
https://t.me/quality_academy_bot
Тест «Подойдёт ли вам тестирование»:
https://quiz.quality-academy.ru
5000+ вопросов с собесов тестировщика — бот-тренажёр:
https://t.me/quality_academy_interview_bot
3 практические задачи и дорожная карта:
https://t.me/quality_academy_tasks_bot
Бесплатные тренажёры — SQL Arena, Playwright Arena, Python Arena:
SQL Arena · Playwright Arena · Python Arena