Перейти к содержанию

SQL JOIN: как соединять таблицы

В хорошо спроектированной базе данные распределены по таблицам: клиенты отдельно, заказы отдельно, товары отдельно. Чтобы собрать из них отчёт — используй JOIN.

В этой статье ты пройдёшь путь от базовых соединений до алгоритмов планировщика PostgreSQL. Все примеры на схеме etl_kitchen_db.


INNER JOIN: только совпавшие строки

INNER JOIN возвращает строки, у которых нашлось совпадение в обеих таблицах. Нет пары — строка не попадает в результат.

Базовый пример

SQL
SELECT c.name, o.order_id, o.total_amount
FROM customers c
INNER JOIN orders o ON c.customer_id = o.customer_id
ORDER BY c.name
LIMIT 5;
name order_id total_amount
Алексей Смирнов 1 89990.00
Алексей Смирнов 18 26980.00
Борис Кузнецов 5 139980.00
Виктория Морозова 3 156970.00
Дмитрий Новиков 4 9770.00

INNER можно опустить

JOIN без указания типа = INNER JOIN. Но для читаемости лучше писать явно.

Множитель строк — это не ошибка

У Алексея Смирнова два заказа — и он появляется в результате дважды. Это нормальная механика: если у одной строки левой таблицы несколько совпадений в правой, каждое совпадение = отдельная строка. На больших таблицах это может неожиданно «раздуть» выборку.

SQL
-- Проверить множитель: сравни количество
SELECT
    (SELECT COUNT(*) FROM customers) AS customers,
    (SELECT COUNT(*) FROM orders) AS orders,
    (SELECT COUNT(*) FROM customers c JOIN orders o USING (customer_id)) AS joined;

Цепочка JOIN: 4 таблицы

Реальные запросы соединяют 3-5 таблиц. Порядок чтения: FROM → первый JOIN → второй JOIN → ...

SQL
-- Полный отчёт: кто, что купил, из какой категории
SELECT
    c.name      AS customer,
    p.name      AS product,
    cat.name    AS category,
    oi.quantity,
    oi.unit_price
FROM customers c
JOIN orders o      ON c.customer_id = o.customer_id
JOIN order_items oi ON o.order_id   = oi.order_id
JOIN products p    ON oi.product_id = p.product_id
JOIN categories cat ON p.category_id = cat.category_id
WHERE o.status = 'delivered'
ORDER BY c.name, p.name;

ON vs USING

Если столбцы связи называются одинаково в обеих таблицах — используй USING:

SQL
-- Эквивалентные запросы:
SELECT * FROM orders JOIN order_items ON orders.order_id = order_items.order_id;
SELECT * FROM orders JOIN order_items USING (order_id);

Нюанс USING

USING убирает дубликат столбца: в результате будет один order_id, а не два. Это удобнее для SELECT *.


LEFT JOIN: все строки из левой таблицы

LEFT JOIN сохраняет все строки из левой (первой) таблицы. Если совпадения в правой нет — подставляется NULL.

SQL
SELECT c.name, o.order_id, o.total_amount
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
ORDER BY o.order_id NULLS LAST;
name order_id total_amount
Алексей Смирнов 1 89990.00
Мария Петрова 2 26980.00
... ... ...
Диана Никитина NULL NULL

Диана зарегистрирована, но ничего не заказала — LEFT JOIN сохранил её с NULL.

Anti-join: кто НЕ сделал действие

SQL
-- Клиенты без заказов
SELECT c.name, c.email
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL;

NOT EXISTS — альтернатива

SQL
SELECT c.name FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id);
Часто NOT EXISTS работает быстрее на больших таблицах.

Критически важно: ON vs WHERE в LEFT JOIN

Это ловушка, на которой ошибаются даже опытные разработчики.

Фильтр в ON — влияет на то, какие строки из правой таблицы «приклеятся»:

SQL
-- Все клиенты, но заказы только со статусом 'delivered'
SELECT c.name, o.order_id, o.status
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
                  AND o.status = 'delivered';
name order_id status
Алексей Смирнов 1 delivered
Ольга Соколова NULL NULL

Ольга осталась — у неё заказ cancelled, но LEFT JOIN сохранил строку с NULL.

Фильтр в WHERE — отсекает строки из готового результата:

SQL
-- LEFT JOIN превратился в INNER JOIN!
SELECT c.name, o.order_id, o.status
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE o.status = 'delivered';
name order_id status
Алексей Смирнов 1 delivered

Ольга исчезла: у неё status = NULL, а NULL = 'delivered' — ложь.

Правило

В LEFT JOIN дополнительные условия по правой таблице пиши в ON, не в WHERE. Иначе LEFT JOIN превращается в INNER JOIN.


RIGHT JOIN и FULL OUTER JOIN

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

FULL OUTER JOIN — все строки из обеих таблиц. Незаменим для сверки данных:

SQL
-- Найти «осиротевшие» записи: заказы без клиентов + клиенты без заказов
SELECT
    c.customer_id AS cust_id,
    c.name,
    o.order_id,
    o.customer_id AS ord_cust_id
FROM customers c
FULL OUTER JOIN orders o ON c.customer_id = o.customer_id
WHERE c.customer_id IS NULL    -- заказ без клиента
   OR o.customer_id IS NULL;   -- клиент без заказа

CROSS JOIN: декартово произведение

CROSS JOIN соединяет каждую строку первой таблицы с каждой строкой второй. N × M строк. Условие ON не нужно.

Когда полезен: заполнение пропусков

SQL
-- Создать сетку «товар × дата» и приклеить продажи (0 если нет)
SELECT
    p.name       AS product,
    d.full_date,
    COALESCE(SUM(f.revenue), 0) AS daily_revenue
FROM products p
CROSS JOIN dim_date d
LEFT JOIN fact_sales f ON p.product_id = f.product_id
                      AND d.date_id = f.date_id
WHERE d.year = 2024 AND d.month = 1
GROUP BY p.name, d.full_date
ORDER BY p.name, d.full_date;

Без CROSS JOIN дни без продаж просто исчезнут из отчёта.

Осторожно с размером

events (100K строк) × products (20 строк) = 2M строк — терпимо. Но events × events = 10 млрд строк — сервер ляжет. Всегда ограничивай выборку фильтрами.


Self-join: таблица сама с собой

Когда таблица ссылается сама на себя (manager_id → employee_id, parent_id → category_id), нужен self-join с обязательными псевдонимами:

SQL
-- Сотрудник → руководитель
SELECT
    e.name  AS employee,
    m.name  AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.employee_id;
employee manager
Иван (CEO) NULL
Анна (PM) Иван (CEO)
Пётр (Dev) Анна (PM)
SQL
-- Дерево категорий: родитель → ребёнок
SELECT
    p.name AS parent_category,
    c.name AS child_category
FROM categories c
LEFT JOIN categories p ON c.parent_id = p.category_id;

NATURAL JOIN: почему его не стоит использовать

NATURAL JOIN автоматически соединяет таблицы по всем столбцам с одинаковыми именами. Звучит удобно, но это бомба замедленного действия:

SQL
-- Сейчас работает: соединяет по customer_id
SELECT * FROM orders NATURAL JOIN customers;

-- Через месяц: разработчик добавил updated_at в обе таблицы
-- Теперь JOIN идёт по customer_id AND updated_at — результат пуст!
Ситуация JOIN ... ON NATURAL JOIN
Добавили столбец updated_at Работает как раньше Возвращает 0 строк
Переименовали PK Явная ошибка (легко починить) Тихий CROSS JOIN

Правило: никогда не используй NATURAL JOIN в продуктовом коде. Если хочешь краткости — используй USING.


Под капотом: алгоритмы JOIN

Планировщик PostgreSQL выбирает один из трёх алгоритмов:

Алгоритм Как работает Когда эффективен
Nested Loop Для каждой строки левой ищет совпадение в правой Маленькие таблицы или есть индекс на ключ
Hash Join Строит хеш-таблицу в памяти, потом сканирует вторую Большие таблицы без индексов
Merge Join Сортирует обе таблицы и сканирует параллельно Огромные таблицы, уже отсортированные
SQL
-- Посмотри, какой алгоритм выбран:
EXPLAIN ANALYZE
SELECT c.name, o.total_amount
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id;

Оптимизатор умнее тебя

PostgreSQL сам выбирает порядок соединения таблиц по оценке стоимости (cost). При 7+ таблицах включается генетический оптимизатор (GEQO). Не пытайся менять порядок вручную — лучше дай СУБД актуальную статистику через ANALYZE.


Типичные ошибки

1. Забыл ON — получил декартово произведение

SQL
-- Опасно! 30 клиентов × 50 заказов = 1500 строк вместо 50
SELECT * FROM customers, orders;

2. LEFT JOIN + INNER JOIN = потеря строк

SQL
-- Клиенты без заказов исчезнут из-за INNER JOIN с order_items
SELECT c.name, oi.quantity
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
JOIN order_items oi ON o.order_id = oi.order_id;  -- ← INNER убивает NULL

-- Правильно: вся цепочка LEFT JOIN
SELECT c.name, oi.quantity
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
LEFT JOIN order_items oi ON o.order_id = oi.order_id;

3. Функция в ON убивает индекс

SQL
-- Плохо: LOWER() не даёт использовать индекс
SELECT * FROM customers c
JOIN orders o ON LOWER(c.email) = LOWER(o.contact_email);

-- Лучше: создай функциональный индекс или нормализуй данные

Шпаргалка

Задача Тип JOIN
Только клиенты с заказами INNER JOIN
Все клиенты + их заказы (если есть) LEFT JOIN
Клиенты без заказов LEFT JOIN + WHERE ... IS NULL
Сверка двух таблиц (найти «дыры») FULL OUTER JOIN
Все комбинации (сетка для отчёта) CROSS JOIN
Иерархия (сотрудник → руководитель) Self-join
Топ-N для каждой группы LATERAL JOIN (см. рекурсивные паттерны)

Что запомнить

  • INNER JOIN — только совпавшие строки, самый частый тип
  • LEFT JOIN — сохраняет все строки левой таблицы, NULL где нет пары
  • В LEFT JOIN фильтры по правой таблице пиши в ON, не в WHERE
  • USING (col) — короче ON a.col = b.col, убирает дубликат столбца
  • NATURAL JOINникогда не используй в продуктовом коде
  • CROSS JOIN — генерация сетки, не забудь ограничить размер
  • Множитель строк — не ошибка: 1 клиент × 5 заказов = 5 строк
  • Смешивать LEFT и INNER JOIN в цепочке опасно — NULL «съест» строки
  • PostgreSQL сам выбирает алгоритм JOIN (Nested Loop, Hash, Merge) — EXPLAIN ANALYZE покажет

Попробуй сам


Проверь себя


Что дальше?

Теперь ты умеешь соединять таблицы. Следующий шаг — группировки и агрегаты (GROUP BY).


Источники