SQL JOIN: как соединять таблицы¶
В хорошо спроектированной базе данные распределены по таблицам: клиенты отдельно, заказы отдельно, товары отдельно. Чтобы собрать из них отчёт — используй JOIN.
В этой статье ты пройдёшь путь от базовых соединений до алгоритмов планировщика PostgreSQL. Все примеры на схеме etl_kitchen_db.
INNER JOIN: только совпавшие строки¶
INNER JOIN возвращает строки, у которых нашлось совпадение в обеих таблицах. Нет пары — строка не попадает в результат.
Базовый пример¶
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. Но для читаемости лучше писать явно.
Множитель строк — это не ошибка¶
У Алексея Смирнова два заказа — и он появляется в результате дважды. Это нормальная механика: если у одной строки левой таблицы несколько совпадений в правой, каждое совпадение = отдельная строка. На больших таблицах это может неожиданно «раздуть» выборку.
-- Проверить множитель: сравни количество
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 → ...
-- Полный отчёт: кто, что купил, из какой категории
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:
-- Эквивалентные запросы:
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.
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: кто НЕ сделал действие¶
-- Клиенты без заказов
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 — альтернатива
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 — влияет на то, какие строки из правой таблицы «приклеятся»:
-- Все клиенты, но заказы только со статусом '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 — отсекает строки из готового результата:
-- 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 — все строки из обеих таблиц. Незаменим для сверки данных:
-- Найти «осиротевшие» записи: заказы без клиентов + клиенты без заказов
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 не нужно.
Когда полезен: заполнение пропусков¶
-- Создать сетку «товар × дата» и приклеить продажи (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 с обязательными псевдонимами:
-- Сотрудник → руководитель
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) |
-- Дерево категорий: родитель → ребёнок
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 автоматически соединяет таблицы по всем столбцам с одинаковыми именами. Звучит удобно, но это бомба замедленного действия:
-- Сейчас работает: соединяет по 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 | Сортирует обе таблицы и сканирует параллельно | Огромные таблицы, уже отсортированные |
-- Посмотри, какой алгоритм выбран:
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 — получил декартово произведение¶
2. LEFT JOIN + INNER JOIN = потеря строк¶
-- Клиенты без заказов исчезнут из-за 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 убивает индекс¶
-- Плохо: 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).
Источники¶
- PostgreSQL: Table Expressions — Joined Tables — синтаксис JOIN, USING, NATURAL, LATERAL
- PostgreSQL: Planner/Optimizer — алгоритмы Nested Loop, Hash Join, Merge Join
- Join (SQL) — Wikipedia — теория, типы соединений, реляционная алгебра