JOIN в SQL на понятных примерах: короткое руководство
JOIN объединяет строки из нескольких таблиц, но результат зависит от типа соединения, условия ON и фильтра WHERE. На примерах PostgreSQL 16 разберём INNER, LEFT, RIGHT, FULL и CROSS JOIN без лишней теории.

Один запрос с JOIN может вернуть заказы вместе с именами клиентов, а может неожиданно потерять строки или умножить их в десять раз. Ниже — короткая практика для PostgreSQL 16: создадим две таблицы, соединим их разными способами и разберём ошибки, из-за которых отчёт показывает не те данные.
Что нужно иметь перед началом
Подойдёт PostgreSQL 16 или совместимый клиент: psql, DBeaver, DataGrip или веб-консоль вашего хостинга. Права на создание временных таблиц не обязательны, если вы замените их на обычные таблицы в отдельной тестовой схеме. Примеры используют только стандартный SQL-синтаксис PostgreSQL и не требуют внешних пакетов.
Создайте тестовые данные одним скриптом:
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS customers;
CREATE TABLE customers (
customer_id integer PRIMARY KEY,
customer_name text NOT NULL
);
CREATE TABLE orders (
order_id integer PRIMARY KEY,
customer_id integer REFERENCES customers(customer_id),
order_total numeric(10, 2) NOT NULL,
order_status text NOT NULL
);
INSERT INTO customers (customer_id, customer_name) VALUES
(1, 'Анна'),
(2, 'Борис'),
(3, 'Вера');
INSERT INTO orders (order_id, customer_id, order_total, order_status) VALUES
(101, 1, 1500.00, 'paid'),
(102, 1, 700.00, 'cancelled'),
(103, 2, 2300.00, 'paid'),
(104, NULL, 900.00, 'new');
Проверка: запросы SELECT * FROM customers; и SELECT * FROM orders; должны показать три клиента и четыре заказа. Заказ 104 специально не связан с клиентом, а у Веры нет заказов.
Как читать JOIN
Представьте две таблицы: слева — customers, справа — orders. Условие после ON говорит, какие строки считаются парой. В нашем случае это совпадение customers.customer_id = orders.customer_id.
Главный вопрос перед JOIN: должны ли в результате остаться строки без пары. INNER JOIN их убирает, LEFT JOIN сохраняет все строки слева.
Первичный ключ — поле, однозначно идентифицирующее строку таблицы. Внешний ключ — поле, которое хранит ссылку на строку другой таблицы. В примере customer_id — первичный ключ у клиентов и внешний ключ у заказов.
Пять видов JOIN на одном наборе данных
1. INNER JOIN: только совпавшие строки
Это самый частый вариант. Он возвращает только клиентов, у которых есть заказ, и только заказы, для которых найден клиент.
SELECT
c.customer_name,
o.order_id,
o.order_total,
o.order_status
FROM customers AS c
INNER JOIN orders AS o
ON o.customer_id = c.customer_id
ORDER BY o.order_id;
В результате будут заказы 101, 102 и 103. Вера не попадёт в выборку: у неё нет пары в orders. Заказ 104 тоже исчезнет, потому что его customer_id равен NULL.
2. LEFT JOIN: все строки из левой таблицы
Если отчёт должен показать каждого клиента, используйте LEFT JOIN. Когда заказа нет, поля правой таблицы получают значение NULL.
SELECT
c.customer_name,
o.order_id,
o.order_total
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
ORDER BY c.customer_id, o.order_id;
Анна появится дважды, потому что у неё два заказа. Борис появится один раз. Вера появится один раз с пустыми order_id и order_total. Это не ошибка: JOIN возвращает строку клиента и показывает, что подходящего заказа нет.
3. RIGHT JOIN: все строки из правой таблицы
RIGHT JOIN сохраняет все строки справа. Тот же результат обычно проще выразить через LEFT JOIN, поменяв таблицы местами:
SELECT
c.customer_name,
o.order_id,
o.order_total
FROM customers AS c
RIGHT JOIN orders AS o
ON o.customer_id = c.customer_id
ORDER BY o.order_id;
Здесь сохранятся все четыре заказа. Для заказа 104 имя клиента будет NULL. На практике многие команды предпочитают переписать такой запрос через LEFT JOIN, чтобы направление чтения не приходилось держать в голове.
4. FULL JOIN: все строки с обеих сторон
FULL JOIN объединяет поведение LEFT и RIGHT. В результат попадут клиенты без заказов и заказы без клиента.
SELECT
c.customer_name,
o.order_id,
o.order_total
FROM customers AS c
FULL JOIN orders AS o
ON o.customer_id = c.customer_id
ORDER BY c.customer_id NULLS LAST, o.order_id;
Такой вариант полезен для сверки источников: например, при поиске записей, которые есть в CRM, но отсутствуют в платёжной системе. Для обычного клиентского списка FULL JOIN чаще всего избыточен.
5. CROSS JOIN: все комбинации
CROSS JOIN не ищет совпадение по ключу. Он создаёт декартово произведение: каждая строка первой таблицы соединяется с каждой строкой второй.
SELECT
c.customer_name,
s.order_status
FROM customers AS c
CROSS JOIN (
VALUES ('new'), ('paid'), ('cancelled')
) AS s(order_status)
ORDER BY c.customer_id, s.order_status;
Три клиента умножаются на три статуса, поэтому получится девять строк. Такой JOIN подходит для построения всех комбинаций, но случайный пропуск условия соединения может породить миллионы строк.
Где ставить фильтр: ON или WHERE
Для INNER JOIN эти варианты часто дают одинаковый результат. Для LEFT JOIN разница принципиальная.
Так мы сохраняем всех клиентов, но присоединяем только оплаченные заказы:
SELECT
c.customer_name,
o.order_id,
o.order_total
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
AND o.order_status = 'paid'
ORDER BY c.customer_id;
Если перенести условие в WHERE, строки без заказа исчезнут:
SELECT
c.customer_name,
o.order_id,
o.order_total
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
WHERE o.order_status = 'paid'
ORDER BY c.customer_id;
После соединения у Веры в o.order_status стоит NULL. Условие o.order_status = 'paid' для неё не истинно, поэтому WHERE отбрасывает строку. Формально запрос всё ещё содержит LEFT JOIN, но по поведению становится похож на INNER JOIN.
Как не получить лишние строки
Проверьте кардинальность
Если одному клиенту соответствуют десять заказов, после JOIN клиент появится десять раз. Это нормальное поведение, а не дубликаты в базе. Ошибка начинается, когда вы затем считаете клиентов через COUNT(*).
SELECT
COUNT(*) AS joined_rows,
COUNT(DISTINCT c.customer_id) AS unique_customers
FROM customers AS c
JOIN orders AS o
ON o.customer_id = c.customer_id;
COUNT(*) считает строки результата JOIN, а COUNT(DISTINCT ...) — уникальных клиентов. Выбирайте выражение по смыслу отчёта.
Не соединяйте таблицы только по похожему имени
Условие ON c.customer_id = o.customer_id связывает сущности по ключу. Соединение по имени клиента, адресу или сумме заказа может дать ложные пары: имена совпадают, адреса меняются, а суммы не уникальны.
Отдельно обработайте NULL
NULL означает отсутствие значения, а не ноль и не пустую строку. Проверка customer_id = NULL не найдёт такие записи. Используйте IS NULL:
SELECT order_id, order_total
FROM orders
WHERE customer_id IS NULL;
Пошаговый алгоритм для рабочего запроса
- Назовите сущность результата. Решите, что означает одна строка: клиент, заказ, платёж или пара клиент–заказ.
- Выберите главную таблицу. Если нужно сохранить всех клиентов, начинайте с
customersи используйтеLEFT JOIN. Проверка: вы можете одним предложением объяснить, какие строки нельзя потерять. - Запишите ключ связи в ON. Не прячьте условие соединения в
WHERE. Проверка: для каждой пары таблиц понятно, какие поля сравниваются. - Добавьте фильтры. Фильтры, которые не должны отбрасывать строки без пары, оставляйте в
ONприLEFT JOIN. Проверка: клиент без заказов остаётся в тестовом результате. - Проверьте количество строк. Сравните результат с
COUNT(*)и, если нужно, сCOUNT(DISTINCT ключ). Проверка: рост числа строк объясняется отношением один-ко-многим, а не случайным CROSS JOIN.
Что ломается первым
- Клиенты пропали. Скорее всего, выбран
INNER JOINвместоLEFT JOINили фильтр по правой таблице попал вWHERE. - Строк стало слишком много. Проверьте, не соединяете ли вы две таблицы «многие-ко-многим» без промежуточной таблицы и не пропущено ли условие
ON. - Все поля правой таблицы равны NULL. Ключи имеют разные типы или значения в них не совпадают. Сначала выполните отдельные запросы к ключам и проверьте пробелы, регистр и формат данных.
- Ошибка column does not exist. Уточните имя поля и используйте псевдонимы таблиц:
c.customer_id,o.customer_id. Если название создано в двойных кавычках, PostgreSQL учитывает его регистр.
Что делать дальше
Возьмите один реальный отчёт и подпишите смысл одной строки результата. Затем сравните три версии: INNER JOIN, LEFT JOIN и тот же LEFT JOIN с фильтром в WHERE. Посмотрите, какие записи исчезли и почему. Для больших таблиц следующим шагом будет EXPLAIN (ANALYZE, BUFFERS): он покажет план, фактическое число строк и место, где запрос тратит время.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: PostgreSQL 16 Documentation: Table Expressions, PostgreSQL 16 Documentation: SELECT
Частые вопросы
Чем INNER JOIN отличается от LEFT JOIN?
Почему LEFT JOIN иногда ведёт себя как INNER JOIN?
Почему после JOIN строк стало больше, чем было в таблице?
Можно ли всегда заменить RIGHT JOIN на LEFT JOIN?
Что делать, если JOIN не находит совпадения?
Обязательно ли писать INNER перед JOIN?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.