LEFT JOIN в SQL возвращает все строки левой таблицы и подходящие строки правой. Если пары нет, строка левой таблицы всё равно попадает в результат, а столбцы правой в ней заполняются NULL. Пишется так: FROM левая LEFT JOIN правая ON левая.ключ = правая.ключ. Слово OUTER можно добавить или опустить, результат один и тот же.
Ниже разбираем левое соединение на четырёх маленьких таблицах: как оно работает, как найти строки без пары и почему условие в WHERE незаметно превращает его во внутреннее. Общая картина всех видов соединений есть в статье «JOIN в SQL: виды соединений таблиц», а здесь только LEFT JOIN и то, на чём с ним ошибаются.
Синтаксис 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)
База делает это в два шага:
- Склеивает строки, у которых условие
ONвыполнилось. Это обычное внутреннее соединение: Анна с заказами 101 и 102, Вера с заказом 103. - Добавляет каждую строку левой таблицы, для которой пары не нашлось, и ставит
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)
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 |
|---|---|---|---|---|---|
| Нейросети для рабочих задач Перейти на сайт курса | 31 290 ₽ | - | 1 месяц | Обзор курса | |
| SQL для анализа данных Перейти на сайт курса | 50 865 ₽ | 6203 ₽/мес. | 2 месяца | Обзор курса | |
| SQL-разработчик: тариф PRO Перейти на сайт курса | 89 900 ₽ | 7491 ₽/мес. | 2 месяца | Обзор курса | |
| SQL-разработчик: тариф Базовый Перейти на сайт курса | 79 900 ₽ | 3329 ₽/мес. | 3 месяца | Обзор курса | |
| Data-Driven подход в разработке продукта: SQL и Yandex DataLens Перейти на сайт курса | 29 990 ₽ | 2999 ₽/мес. | 1 месяц | Обзор курса |
Больше программ — в полном каталоге курсов по SQL
Если хотите работать с базами данных профессионально, загляните в план «Как стать SQL-разработчиком», а курсы по конкретной СУБД собраны на странице курсов по PostgreSQL.




