Основы SQL: работа с таблицами и виды JOIN

Основы SQL: работа с таблицами и виды JOIN Полезное

SQL (Structured Query Language) — язык запросов к реляционным базам данных: им создают таблицы, кладут и достают строки, а нескольких таблиц связывают между собой. В этой статье я разберу два блока основ: работу с одной таблицей (CREATE TABLE, INSERT, SELECT, WHERE) и соединение таблиц — виды JOIN (INNER, LEFT, RIGHT, FULL, CROSS, SELF) с примерами и результатом каждого запроса.

Реляционная база — это набор таблиц, где строка описывает объект, а колонка — его свойство. Отдельная таблица редко хранит все сразу: клиенты в одной, их заказы в другой. JOIN нужен, чтобы собрать из них единый результат по общему полю (ключу). Примеры я привожу на стандартном SQL; где диалекты расходятся, помечаю это явно.

Создаем таблицу: CREATE TABLE

Таблицу описывают командой CREATE TABLE: имя таблицы, список колонок и тип каждой колонки. Заведу две таблицы — клиентов и их заказы. Они пройдут через все примеры ниже.

CREATE TABLE customers (
    customer_id INTEGER PRIMARY KEY,
    name        VARCHAR(50) NOT NULL
);

CREATE TABLE orders (
    order_id    INTEGER PRIMARY KEY,
    customer_id INTEGER,
    amount      INTEGER,
    FOREIGN KEY (customer_id) REFERENCES customers(customer_id)
);

Здесь PRIMARY KEY — первичный ключ, уникальный идентификатор строки. FOREIGN KEY (внешний ключ) в orders указывает, что customer_id ссылается на клиента. Колонку orders.customer_id я оставил без NOT NULL намеренно: это позволит завести заказ без клиента (гостевой), и на нем будет видно поведение внешних соединений.

Замечание про индексы по диалектам: под PRIMARY KEY СУБД сама создает уникальный индекс. А вот под FOREIGN KEY индекс автоматически создается не везде — например, в PostgreSQL под внешний ключ индекс не создается, его добавляют вручную, а в MySQL/InnoDB создается. Это стоит держать в голове при оптимизации соединений.

Наполняем данными: INSERT

Строки добавляют командой INSERT INTO. Заполню обе таблицы так, чтобы разные виды JOIN дали разный результат.

INSERT INTO customers (customer_id, name) VALUES
    (1, 'Anna'),
    (2, 'Boris'),
    (3, 'Vera'),
    (4, 'Gleb');

INSERT INTO orders (order_id, customer_id, amount) VALUES
    (101, 1,    500),
    (102, 1,    300),
    (103, 2,    700),
    (104, NULL, 200);

Что здесь спрятано специально:

  • у клиента Anna (id 1) два заказа, у Boris (id 2) один;
  • у Vera (id 3) и Gleb (id 4) заказов нет вовсе;
  • заказ 104 гостевой — customer_id в нем NULL, ему не соответствует ни один клиент.

Именно эти три случая (клиент без заказов, заказ без клиента) и разводят виды соединений.

Выборка: SELECT и WHERE

SELECT достает данные, WHERE фильтрует строки по условию. Простейшая выборка всех клиентов:

SELECT customer_id, name FROM customers;
customer_id | name
------------+-------
1           | Anna
2           | Boris
3           | Vera
4           | Gleb

Добавлю фильтр — только заказы дороже 250:

SELECT order_id, amount FROM orders WHERE amount > 250;
order_id | amount
---------+-------
101      | 500
102      | 300
103      | 700

Заказ 104 с суммой 200 в результат не попал — условие amount > 250 для него ложно. Важная деталь про NULL: сравнение вида column = NULL не работает, NULL проверяют через IS NULL / IS NOT NULL. Это пригодится ниже.

Зачем нужны соединения

Данные разложены по двум таблицам, а вопрос общий: «кто из клиентов что заказал». Ответ требует связать строки customers и orders по общему полю customer_id. Это и делает JOIN — соединяет строки двух таблиц по условию (обычно равенство ключей) в одну результирующую строку.

Различать стоит две вещи. Условие соединения (ON ...) — по какому признаку строки считаются парой. Вид соединения (INNER/LEFT/RIGHT/FULL) — что делать со строками без пары. Дальше я иду по видам на одних и тех же данных.

INNER JOIN — только совпадения

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

SELECT c.name, o.order_id, o.amount
FROM customers c
INNER JOIN orders o ON c.customer_id = o.customer_id
ORDER BY o.order_id;
name  | order_id | amount
------+----------+-------
Anna  | 101      | 500
Anna  | 102      | 300
Boris | 103      | 700

Три строки. Vera и Gleb выпали — у них нет заказов. Гостевой заказ 104 тоже выпал: его customer_id равен NULL, а NULL не равен ни одному значению, условие не выполняется. Псевдонимы c и o (алиасы после имени таблицы) сокращают запись, дальше пользуюсь ими.

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

LEFT JOIN (полное имя LEFT OUTER JOIN, слово OUTER необязательно) возвращает все строки левой таблицы, а из правой подставляет совпадения. Если пары в правой нет — ее колонки заполняются NULL.

SELECT c.name, o.order_id, o.amount
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
ORDER BY c.customer_id, o.order_id;
name  | order_id | amount
------+----------+-------
Anna  | 101      | 500
Anna  | 102      | 300
Boris | 103      | 700
Vera  | NULL     | NULL
Gleb  | NULL     | NULL

Теперь строк пять: Vera и Gleb остались, потому что они в левой таблице, но их заказы — NULL. Это самый частый вид соединения на практике: «покажи всех клиентов и их заказы, включая тех, кто еще ничего не купил».

Из LEFT JOIN получается и типовой прием «анти-соединение» — найти строки левой таблицы без пары справа. Условие ставят на колонку правой таблицы через IS NULL:

SELECT c.name
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL;
name
------
Vera
Gleb

Так находят клиентов без заказов, товары без продаж и подобные «пробелы».

RIGHT JOIN — все строки правой таблицы

RIGHT JOIN (RIGHT OUTER JOIN) — зеркало LEFT: возвращает все строки правой таблицы, из левой подставляет совпадения, а где пары нет — NULL.

SELECT c.name, o.order_id, o.amount
FROM customers c
RIGHT JOIN orders o ON c.customer_id = o.customer_id
ORDER BY o.order_id;
name  | order_id | amount
------+----------+-------
Anna  | 101      | 500
Anna  | 102      | 300
Boris | 103      | 700
NULL  | 104      | 200

Четыре строки — по числу заказов. Гостевой заказ 104 попал в результат, но имя клиента у него NULL: подходящего клиента нет. Vera и Gleb, наоборот, исчезли — в правой таблице (orders) их нет.

На практике RIGHT JOIN встречается реже: тот же результат обычно пишут через LEFT JOIN, поменяв таблицы местами. Диалектная деталь: RIGHT JOIN и FULL JOIN поддерживает не всякая СУБД (таблица в конце статьи).

FULL OUTER JOIN — объединение обеих сторон

FULL OUTER JOIN возвращает все строки обеих таблиц: где пара есть — соединяет, где нет — подставляет NULL с недостающей стороны. По сути это объединение результатов LEFT и RIGHT.

SELECT c.name, o.order_id, o.amount
FROM customers c
FULL OUTER JOIN orders o ON c.customer_id = o.customer_id
ORDER BY c.customer_id, o.order_id;
name  | order_id | amount
------+----------+-------
Anna  | 101      | 500
Anna  | 102      | 300
Boris | 103      | 700
Vera  | NULL     | NULL
Gleb  | NULL     | NULL
NULL  | 104      | 200

Шесть строк: три пары с совпадением, два клиента без заказов (Vera, Gleb) и один заказ без клиента (104). FULL OUTER JOIN удобен, когда важны обе несостыковки сразу — и «клиенты без заказов», и «заказы без клиента».

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

CROSS JOIN соединяет каждую строку левой таблицы с каждой строкой правой. Условия ON у него нет. Если в левой a строк, а в правой b, результат — a x b строк.

SELECT c.name, o.order_id
FROM customers c
CROSS JOIN orders o;

У меня 4 клиента и 4 заказа, значит результат — 16 строк (первые три показаны для иллюстрации):

name | order_id
-----+---------
Anna | 101
Anna | 102
Anna | 103
...  | ...      (всего 16 строк)

Это и есть декартово произведение. Применяют его редко и осознанно — например, чтобы сгенерировать все сочетания (размеры x цвета). Случайный CROSS JOIN на больших таблицах дает взрывной рост строк, поэтому за пропущенным условием соединения следят особо.

SELF JOIN — таблица сама с собой

SELF JOIN — не отдельный вид, а прием: таблицу соединяют с ней же, дав ей два разных псевдонима. Классический случай — иерархия «сотрудник — руководитель» в одной таблице.

CREATE TABLE employees (
    emp_id     INTEGER PRIMARY KEY,
    name       VARCHAR(50),
    manager_id INTEGER
);

INSERT INTO employees (emp_id, name, manager_id) VALUES
    (1, 'Ivan', NULL),
    (2, 'Petr', 1),
    (3, 'Olga', 1);

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

SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.emp_id;
employee | manager
---------+--------
Ivan     | NULL
Petr     | Ivan
Olga     | Ivan

У Ivan руководителя нет (manager_id = NULL), поэтому в колонке manager стоит NULL. У Petr и Olga руководитель — Ivan. Псевдонимы e (сотрудник) и m (руководитель) здесь обязательны: без них СУБД не отличит одну «копию» таблицы от другой.

Порядок выполнения и производительность

Читается запрос сверху вниз, а логически выполняется иначе: сначала FROM и JOIN (формируют набор строк), затем WHERE (фильтрует), и только потом SELECT. Отсюда следствие: WHERE o.order_id IS NULL работает уже после соединения — на результате JOIN, а не на исходной таблице.

Для скорости соединений полезны индексы по колонкам из ON. Индекс под первичный ключ создается автоматически, под остальные колонки (в том числе внешние ключи в ряде СУБД) — вручную. План запроса стоит смотреть через EXPLAIN целевой СУБД: он покажет, использован ли индекс.

Поддержка видов JOIN по СУБД

Синтаксис соединений в основном общий, но крайние виды поддержаны не везде. Ориентиры на сентябрь 2026:

Вид JOIN PostgreSQL 18 MySQL 8 SQLite
INNER JOIN да да да
LEFT JOIN да да да
RIGHT JOIN да да с 3.39 (2022)
FULL OUTER JOIN да нет (эмулируют через UNION) с 3.39 (2022)
CROSS JOIN да да да

В MySQL FULL OUTER JOIN собирают вручную — объединением LEFT JOIN и RIGHT JOIN через UNION. Старые сборки SQLite (до 3.39) не знают RIGHT/FULL JOIN, поэтому эти виды доступны не в любой сборке.

Выводы

  • Работа с одной таблицей строится на четырех командах: CREATE TABLE (структура), INSERT (данные), SELECT (выборка), WHERE (фильтр).
  • JOIN связывает таблицы по общему полю; условие соединения (ON) и вид соединения — разные вещи: первое задает пару, второе решает судьбу строк без пары.
  • INNER JOIN оставляет только совпадения; LEFT/RIGHT сохраняют все строки одной стороны; FULL OUTER — обеих; несостыковки заполняются NULL.
  • CROSS JOIN дает декартово произведение (a x b строк), SELF JOIN — прием соединения таблицы с собой для иерархий.
  • RIGHT и FULL JOIN есть не во всех СУБД: в MySQL FULL эмулируют через UNION, в SQLite они появились с версии 3.39.

Где применяется / связь с практикой

Соединения — повседневный инструмент аналитика, бэкенд-разработчика и дата-инженера: собрать заказы с именами клиентов, найти товары без продаж, построить иерархию сотрудников, свести данные из нескольких таблиц в один отчет. Понимание, что именно возвращает каждый вид JOIN и как ведет себя NULL, отличает рабочий запрос от тихо неверного.

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

Разобрать соединения, подзапросы, агрегаты и оптимизацию на практических задачах помогает курс SQL для разработчиков и аналитиков. Посмотреть формат и уровень занятий можно на бесплатных открытых уроках Otus — там разбирают реальные запросы вживую.

FAQ

Чем JOIN отличается от INNER JOIN?
Ничем: JOIN без уточнения означает INNER JOIN. Слова INNER, а также OUTER в LEFT/RIGHT/FULL OUTER JOIN можно опускать — на результат это не влияет.

Почему строка с NULL в ключе не попадает в INNER JOIN?
Условие соединения проверяет равенство, а NULL не равен ничему, даже другому NULL (результат сравнения — «неизвестно»). Поэтому строки с NULL в колонке соединения выпадают из INNER JOIN и требуют внешнего соединения, если их нужно сохранить.

Как получить FULL OUTER JOIN в MySQL?
Прямой поддержки нет. Результат собирают объединением: LEFT JOIN двух таблиц UNION RIGHT JOIN тех же таблиц. UNION уберет продублированные совпадающие строки.

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)