LEFT JOIN в SQL: как работает левое соединение таблиц на примерах

LEFT JOIN возвращает все строки левой таблицы, а там, где пары справа нет, ставит NULL.

Разбираем на четырёх таблицах с выводом psql: как найти строки без пары, почему фильтр в WHERE превращает соединение во внутреннее, как не задвоить суммы при нескольких JOIN и где окажутся NULL при сортировке.

Статью написал:
Ваня Буявец, продюсер, основатель Checkroi
Ваня Буявец
Основатель Checkroi, продюсер, эксперт в выборе онлайн-курсов
Все 3192 статьи автора Подписаться на Телеграм-канал
Одобрено экспертом:
Наташа Буявец, основатель Checkroi, эксперт по онлайн-курсам
Наташа Буявец
Основательница Checkroi, продюсер Youtube-каналов, эксперт по онлайн-курсам
Все 3846 экспертных мнений Подписаться на Телеграм-канал
Обложка: LEFT JOIN в SQL: как работает левое соединение таблиц на примерах

LEFT JOIN в SQL возвращает все строки левой таблицы и подходящие строки правой. Если пары нет, строка левой таблицы всё равно попадает в результат, а столбцы правой в ней заполняются NULL. Пишется так: FROM левая LEFT JOIN правая ON левая.ключ = правая.ключ. Слово OUTER можно добавить или опустить, результат один и тот же.

Ниже разбираем левое соединение на четырёх маленьких таблицах: как оно работает, как найти строки без пары и почему условие в WHERE незаметно превращает его во внутреннее. Общая картина всех видов соединений есть в статье «JOIN в SQL: виды соединений таблиц», а здесь только LEFT JOIN и то, на чём с ним ошибаются.

CheckroiCheckroiПодборка курсов по SQL422 курса • 40 школСравните цены, школы, программу и найдите выгодные предложения по обучениюСравнить→

Синтаксис LEFT JOIN и учебные таблицы

Рой соединяет две таблицы на экране ноутбука

Базовая форма запроса:

SELECT столбцы
FROM левая_таблица
LEFT JOIN правая_таблица ON условие_соединения;

Левая таблица та, что стоит после FROM, правая идёт после LEFT JOIN. От порядка зависит, чьи строки сохранятся целиком, поэтому таблицы местами без нужды не меняйте.

В разговоре его часто называют «лефт джоин», а в учебниках левым внешним соединением. LEFT JOIN и LEFT OUTER JOIN означают одно и то же. В документации PostgreSQL сказано: «The words INNER and OUTER are optional in all forms», то есть слова INNER и OUTER необязательны в любой форме.

Все примеры дальше идут на этих таблицах: покупатели, их заказы, отзывы и доставки. Если сам SELECT пока непривычен, сначала пригодится разбор команды SELECT от WHERE до JOIN. Создать такие таблицы у себя можно в любой учебной базе, а задачи с проверкой есть на курсах по SQL.

CREATE TABLE customers (id INT PRIMARY KEY, name VARCHAR(20), city VARCHAR(20));
CREATE TABLE orders (id INT PRIMARY KEY, customer_id INT, amount INT, status VARCHAR(20));
CREATE TABLE reviews (id INT PRIMARY KEY, customer_id INT, stars INT);
CREATE TABLE deliveries (order_id INT PRIMARY KEY, state VARCHAR(20));

INSERT INTO customers VALUES (1,'Анна','Москва'),(2,'Борис','Казань'),(3,'Вера','Москва'),(4,'Глеб','Пермь');
INSERT INTO orders VALUES (101,1,2500,'paid'),(102,1,1200,'cancelled'),(103,3,4000,'paid'),(104,5,700,'paid'),(105,NULL,300,'paid');
INSERT INTO reviews VALUES (1,1,5),(2,1,4),(3,3,5);
INSERT INTO deliveries VALUES (101,'доставлен'),(103,'в пути');

У Анны два заказа, у Веры один, у Бориса и Глеба ни одного. Заказ 104 ссылается на покупателя 5, которого нет, а у заказа 105 покупатель не указан вовсе. Вывод ниже в формате psql. По умолчанию psql показывает пустое значение пустой клеткой, а чтобы видеть слово NULL, как здесь, выполните \pset null NULL.

Как работает LEFT JOIN на примере

Выведем всех покупателей и их заказы:

SELECT c.name, o.id AS order_id, o.amount
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
ORDER BY c.id, o.id;

 name  | order_id | amount
-------+----------+--------
 Анна  |      101 |   2500
 Анна  |      102 |   1200
 Борис |     NULL |   NULL
 Вера  |      103 |   4000
 Глеб  |     NULL |   NULL
(5 rows)

База делает это в два шага:

  1. Склеивает строки, у которых условие ON выполнилось. Это обычное внутреннее соединение: Анна с заказами 101 и 102, Вера с заказом 103.
  2. Добавляет каждую строку левой таблицы, для которой пары не нашлось, и ставит NULL во все столбцы правой. Так в результат попадают Борис и Глеб.

Именно так левое внешнее соединение описано в руководстве PostgreSQL: сначала выполняется внутреннее соединение, затем для строк без пары добавляются строки с NULL в столбцах второй таблицы, поэтому в результате всегда есть хотя бы одна строка на каждую строку левой таблицы.

Строк при этом может стать больше, чем в левой таблице. Покупателей четыре, а строк пять: у Анны два заказа, и она повторилась дважды. Заказы 104 и 105 в результат не попали, потому что у них нет пары среди покупателей, а их таблица стоит справа.

Канал основателя Checkroi Вани Буявца3 800 человек читают мой Телеграм-канал про нейросетиСобрал промпты для Claude Code и ChatGPT, разборы ИИ-инструментов и лайфхаки по продвижению бизнеса в одном местеПрисоединиться

Как найти строки без пары

Две стопки карточек: у части карточек слева нет пары справа

Самая частая практическая задача: найти покупателей без заказов, товары без продаж, сотрудников без отдела. Делается это левым соединением и проверкой IS NULL по ключу правой таблицы:

SELECT c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;

 name
-------
 Борис
 Глеб
(2 rows)

Проверяйте столбец, который в правой таблице не бывает пустым сам по себе, лучше всего первичный ключ. Если взять столбец, где NULL встречается и в настоящих строках, в результат попадут лишние записи. Почему с NULL нельзя сравнивать через знак равенства, подробно разобрано в статье о NULL в SQL.

Тот же ответ даёт NOT EXISTS:

SELECT c.name FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);

 name
-------
 Борис
 Глеб
(2 rows)

А вот NOT IN на этих данных ломается:

SELECT name FROM customers
WHERE id NOT IN (SELECT customer_id FROM orders);

 name
------
(0 rows)

Ноль строк, хотя Борис и Глеб на месте. Виноват заказ 105 с пустым customer_id. В описании NOT IN сказано, что если совпадений нет и хотя бы одна строка подзапроса даёт null, результат NOT IN «will be null, not true». Строка с null в WHERE не проходит, и выборка пустеет.

Способ Пустые значения в правой таблице Когда брать
LEFT JOIN … WHERE ключ IS NULL работает когда из правой таблицы нужны ещё и столбцы
NOT EXISTS работает когда нужен только факт, что пары нет
NOT IN (подзапрос) возвращает ноль строк только если в столбце подзапроса точно нет NULL

Условие в ON или в WHERE

Рой радуется, что вернул пропавшие строки в отчёт

Это главная ловушка левого соединения. Допустим, нужны все покупатели и только их оплаченные заказы. Первая попытка обычно такая:

SELECT c.name, o.id AS order_id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid'
ORDER BY c.id;

 name | order_id
------+----------
 Анна |      101
 Вера |      103
(2 rows)

Борис и Глеб пропали. У них o.status равен NULL, а сравнение NULL = 'paid' не даёт истины, и WHERE такие строки отбрасывает. Фильтр по правой таблице в WHERE превратил LEFT JOIN во внутреннее соединение.

Решение: перенести условие в ON.

SELECT c.name, o.id AS order_id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid'
ORDER BY c.id;

 name  | order_id
-------+----------
 Анна  |      101
 Борис |     NULL
 Вера  |      103
 Глеб  |     NULL
(4 rows)

Теперь отменённый заказ 102 просто не считается парой, а покупатели остаются все. Руководство PostgreSQL объясняет разницу так: условие в ON обрабатывается до соединения, условие в WHERE после. Для внутренних соединений это неважно, для внешних «it matters a lot».

То, что база сама считает первый запрос внутренним соединением, видно по плану PostgreSQL:

EXPLAIN (COSTS OFF)
SELECT c.name, o.id FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid';

                      QUERY PLAN
-------------------------------------------------------
 Hash Join
   Hash Cond: (c.id = o.customer_id)
   ->  Seq Scan on customers c
   ->  Hash
         ->  Seq Scan on orders o
               Filter: ((status)::text = 'paid'::text)
(6 rows)

В плане стоит Hash Join, а не Hash Left Join: планировщик понял, что строки с NULL всё равно отсеются, и выбросил внешнюю часть.

Короткое правило:

  • условие на правую таблицу, которое решает, что считать парой, пишите в ON;
  • условие на левую таблицу, которое решает, какие строки вообще показывать, пишите в WHERE;
  • проверка правая.ключ IS NULL в WHERE законна, это поиск строк без пары.

LEFT JOIN и агрегаты: COUNT, SUM, COALESCE

Отчёт «сколько заказов у каждого покупателя» почти всегда строится на левом соединении, иначе покупатели с нулём выпадут. Но и тут есть подвох с подсчётом:

SELECT c.name, COUNT(*) AS wrong, COUNT(o.id) AS orders_cnt, COALESCE(SUM(o.amount), 0) AS total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name
ORDER BY c.id;

 name  | wrong | orders_cnt | total
-------+-------+------------+-------
 Анна  |     2 |          2 |  3700
 Борис |     1 |          0 |     0
 Вера  |     1 |          1 |  4000
 Глеб  |     1 |          0 |     0
(4 rows)

COUNT(*) считает строки, а у Бориса одна строка есть, пусть и с NULL. Поэтому в столбце wrong у него стоит единица. COUNT(o.id) пропускает пустые значения и даёт честный ноль. Разница между формами подсчёта подробно расписана в статье о функции COUNT.

С суммой похожая история: SUM по одним NULL возвращает NULL, а не ноль. Обёртка COALESCE подставляет ноль, и в отчёте не остаётся пустых клеток.

Несколько LEFT JOIN подряд

В реальных запросах соединений обычно два-три. Здесь ошибаются двумя способами.

Один INNER JOIN в цепочке отменяет LEFT

Добавим к заказам статус доставки, но вторую связку напишем просто через JOIN:

SELECT c.name, o.id AS order_id, d.state
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
JOIN deliveries d ON d.order_id = o.id
ORDER BY c.id;

 name | order_id |   state
------+----------+-----------
 Анна |      101 | доставлен
 Вера |      103 | в пути
(2 rows)

Внутреннее соединение с deliveries требует пары, а у Бориса, Глеба и заказа 102 её нет. Решение: писать LEFT JOIN во всех звеньях цепочки после первой левой таблицы.

SELECT c.name, o.id AS order_id, d.state
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
LEFT JOIN deliveries d ON d.order_id = o.id
ORDER BY c.id, o.id;

 name  | order_id |   state
-------+----------+-----------
 Анна  |      101 | доставлен
 Анна  |      102 | NULL
 Борис |     NULL | NULL
 Вера  |      103 | в пути
 Глеб  |     NULL | NULL
(5 rows)

Два соединения «один ко многим» задваивают суммы

Теперь посчитаем для каждого покупателя сумму заказов и число отзывов одним запросом:

SELECT c.name, SUM(o.amount) AS total, COUNT(r.id) AS reviews_cnt
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
LEFT JOIN reviews r ON r.customer_id = c.id
GROUP BY c.id, c.name
ORDER BY c.id;

 name  | total | reviews_cnt
-------+-------+-------------
 Анна  |  7400 |           4
 Борис |  NULL |           0
 Вера  |  4000 |           1
 Глеб  |  NULL |           0
(4 rows)

У Анны заказов на 3 700 ₽ и два отзыва, а запрос показал 7 400 и четыре. Каждый её заказ склеился с каждым отзывом: 2 × 2 = 4 строки, и сумма посчиталась дважды. DISTINCT тут не поможет: если у покупателя два заказа по 2 500 ₽, он оставит одну сумму из двух.

Решение: сначала свернуть каждую таблицу до одной строки на покупателя в подзапросе, а потом соединять.

SELECT c.name, COALESCE(o.total, 0) AS total, COALESCE(r.cnt, 0) AS reviews_cnt
FROM customers c
LEFT JOIN (SELECT customer_id, SUM(amount) AS total FROM orders GROUP BY customer_id) o ON o.customer_id = c.id
LEFT JOIN (SELECT customer_id, COUNT(*) AS cnt FROM reviews GROUP BY customer_id) r ON r.customer_id = c.id
ORDER BY c.id;

 name  | total | reviews_cnt
-------+-------+-------------
 Анна  |  3700 |           2
 Борис |     0 |           0
 Вера  |  4000 |           1
 Глеб  |     0 |           0
(4 rows)
Ваня БуявецКанал основателя Checkroi Вани БуявцаЗабирайте промпты и обучение по нейросетям в моём Телеграм-каналеБольше 3 800 человек уже применяют Claude Code, ChatGPT и другие нейросети в работе, учёбе, бизнесе и жизниПерейти в канал

LEFT JOIN и ORDER BY: где окажутся NULL

После левого соединения в столбцах правой таблицы есть пустые значения, и сортировка по ним ведёт себя в разных СУБД по-разному. Запрос один:

SELECT c.name, o.amount
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid'
ORDER BY o.amount, c.id;

PostgreSQL ставит NULL в конец, SQLite в начало:

PostgreSQL SQLite
Анна 2500 Борис NULL
Вера 4000 Глеб NULL
Борис NULL Анна 2500
Глеб NULL Вера 4000

В документации PostgreSQL это поведение по умолчанию: null сортируется так, будто он больше любого значения, то есть при возрастании идёт последним. В справке MySQL наоборот: при ORDER BY … ASC значения NULL «are presented first».

В PostgreSQL порядок задаётся явно: ORDER BY o.amount NULLS LAST или NULLS FIRST. Как сортировать по нескольким столбцам и менять направление, разобрано в статье про ORDER BY.

Последняя запись из правой таблицы: LEFT JOIN LATERAL

Частая задача: показать каждого покупателя с его последним заказом, а не со всеми сразу. В PostgreSQL для этого есть LEFT JOIN LATERAL: подзапрос справа видит столбцы левой таблицы и выполняется для каждой её строки.

SELECT c.name, last.id AS order_id, last.amount
FROM customers c
LEFT JOIN LATERAL (
  SELECT o.id, o.amount
  FROM orders o
  WHERE o.customer_id = c.id
  ORDER BY o.id DESC
  LIMIT 1
) last ON true
ORDER BY c.id;

 name  | order_id | amount
-------+----------+--------
 Анна  |      102 |   1200
 Борис |     NULL |   NULL
 Вера  |      103 |   4000
 Глеб  |     NULL |   NULL
(4 rows)

Именно в паре с LEFT такой подзапрос удобнее всего: руководство PostgreSQL отмечает, что так исходные строки остаются в результате, даже если подзапрос для них ничего не вернул. Условие ON true значит, что фильтр уже внутри подзапроса.

Там, где LATERAL нет, ту же задачу решают оконной функцией ROW_NUMBER: нумеруют заказы внутри покупателя и соединяют только первый номер.

LEFT, RIGHT и INNER: что выбрать

Соединение Что остаётся в результате Типичная задача
INNER JOIN только пары заказы с данными покупателя
LEFT JOIN все строки левой таблицы все покупатели, даже без заказов
RIGHT JOIN все строки правой таблицы то же, что LEFT, с таблицами наоборот

RIGHT JOIN всегда можно переписать как LEFT JOIN, поменяв таблицы местами. Например, найти заказы без существующего покупателя:

SELECT o.id AS order_id, o.amount, c.name
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
ORDER BY o.id;

 order_id | amount | name
----------+--------+------
      101 |   2500 | Анна
      102 |   1200 | Анна
      103 |   4000 | Вера
      104 |    700 | NULL
      105 |    300 | NULL
(5 rows)

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

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

Сравнивать их по скорости бессмысленно: они отвечают на разные вопросы и возвращают разное число строк. На время запроса сильнее влияет другое: есть ли индекс на столбце соединения в правой таблице. Здесь это orders.customer_id.

С индексами есть различие между СУБД, которое легко пропустить:

  • в PostgreSQL объявление внешнего ключа индекс не создаёт: в разделе об ограничениях сказано, «the declaration of a foreign key constraint does not automatically create an index on the referencing columns»;
  • в MySQL индекс на ссылающихся столбцах появляется сам: справка MySQL пишет, что он «is created on the referencing table automatically if it does not exist».

Проверить, используется ли индекс, можно командой EXPLAIN перед запросом, как в разделе про ON и WHERE выше.

LEFT JOIN в SQLAlchemy

В Python-библиотеке SQLAlchemy левое соединение пишется методом outerjoin() или через join() с параметром isouter=True. В справочнике SQLAlchemy про этот параметр сказано: «if True, render a LEFT OUTER JOIN, instead of JOIN». Ловушка с условием в WHERE там та же самая, потому что библиотека генерирует обычный SQL.

Частые ошибки с LEFT JOIN

  • Строки без пары пропали. Причина: фильтр по правой таблице стоит в WHERE или дальше по цепочке есть INNER JOIN. Решение: условие в ON, все последующие соединения левые.
  • Строк больше, чем в левой таблице. Причина: связь «один ко многим», у одной строки слева несколько пар справа. Это нормально, но для отчёта по одной строке на запись сначала агрегируйте правую таблицу.
  • Сумма выросла вдвое. Причина: два соединения «один ко многим» в одном запросе. Решение: свернуть каждую таблицу в подзапросе до соединения.
  • У записей без пары счётчик равен единице. Причина: COUNT(*). Решение: COUNT(правая.ключ).
  • NOT IN вернул пустоту. Причина: NULL в столбце подзапроса. Решение: NOT EXISTS или LEFT JOIN … IS NULL.
  • Ошибка column reference "id" is ambiguous. Причина: столбец id есть в обеих таблицах, и PostgreSQL не знает, какой брать. Решение: всегда писать с псевдонимом, c.id или o.id.

Задачи на соединения, которые дают на собеседованиях, собраны в материале о собеседовании аналитика данных.

Где освоить SQL системно

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

КурсШколаСтоимость со скидкойВ рассрочкуДлитель­ностьОбзор курса от Checkroi
Нейросети для рабочих задач
Перейти на сайт курса
SkillboxSkillbox31 290 ₽ - 1 месяцОбзор курса
SQL для анализа данных
Перейти на сайт курса
SkillboxSkillbox50 865 ₽6203 ₽/мес.2 месяцаОбзор курса
SQL-разработчик: тариф PRO
Перейти на сайт курса
Академия ЭдюсонЭдюсон89 900 ₽7491 ₽/мес.2 месяцаОбзор курса
SQL-разработчик: тариф Базовый
Перейти на сайт курса
Академия ЭдюсонЭдюсон79 900 ₽3329 ₽/мес.3 месяцаОбзор курса
Data-Driven подход в разработке продукта: SQL и Yandex DataLens
Перейти на сайт курса
ProductStarProductStar29 990 ₽2999 ₽/мес.1 месяцОбзор курса

Больше программ — в полном каталоге курсов по SQL

Если хотите работать с базами данных профессионально, загляните в план «Как стать SQL-разработчиком», а курсы по конкретной СУБД собраны на странице курсов по PostgreSQL.

Часто задаваемые вопросы

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

Ничем. По документации PostgreSQL слова INNER и OUTER необязательны в любой форме соединения, поэтому LEFT JOIN и LEFT OUTER JOIN дают один и тот же результат.

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

INNER JOIN оставляет только строки, у которых нашлась пара в обеих таблицах. LEFT JOIN сохраняет все строки левой таблицы, а где пары нет, ставит NULL в столбцы правой. Все виды соединений разобраны в статье «JOIN в SQL».

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

Только тем, какая таблица сохраняется целиком: при LEFT JOIN левая, при RIGHT JOIN правая. Любой RIGHT JOIN можно переписать как LEFT JOIN, поменяв таблицы местами.

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

Если у одной строки слева несколько пар справа, она повторится столько раз, сколько пар нашлось. Так работает связь «один ко многим». Если нужна одна строка на запись, сначала сгруппируйте правую таблицу в подзапросе.

Почему LEFT JOIN работает как INNER JOIN?

Чаще всего в WHERE стоит условие на столбец правой таблицы, например WHERE o.status = 'paid'. У строк без пары там NULL, сравнение не проходит, и они отсеиваются. Перенесите условие в ON.

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

Сравнивать их по скорости не имеет смысла: они возвращают разные строки. На время запроса сильнее влияет индекс на столбце соединения в правой таблице. В PostgreSQL внешний ключ сам индекс не создаёт, его добавляют отдельно.

Читайте Checkroi первым в Google
Добавьте Checkroi в избранные источники — и наши разборы курсов и обзоры школ будут показываться выше в вашей выдаче Google.
Оставить комментарий
0 комментариев
Форма комментария

Оставьте комментарий

Напишите, что думаете. Нам важно ваше мнение!