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

SQL: операции над наборами

Иногда нужно объединить результаты нескольких запросов в одну таблицу, найти пересечение двух выборок или исключить строки одного набора из другого. Для этого в SQL есть четыре оператора: UNION ALL, UNION, INTERSECT и EXCEPT.

Эти операции — рабочие лошадки ETL: сбор данных из нескольких источников, дедупликация, сверка витрин. Все примеры на схеме etl_kitchen_db.


Правила для всех операций

Перед тем как начать — три правила, общие для UNION, INTERSECT и EXCEPT:

  1. Одинаковое количество колонок в обоих запросах
  2. Совместимые типы данных (PostgreSQL попробует привести автоматически, но лучше явно)
  3. Имена колонок берутся из первого запроса
SQL
-- Ошибка: разное количество колонок
SELECT name, email FROM customers
UNION ALL
SELECT name FROM employees;   -- ← не хватает второй колонки

-- Правильно: добавь заглушку
SELECT name, email FROM customers
UNION ALL
SELECT name, NULL AS email FROM employees;

UNION ALL: склеить всё

UNION ALL — самый простой и быстрый оператор. Просто ставит результаты один под другой. Дубликаты сохраняются.

Единый поток событий из разных таблиц

SQL
-- Собираем лог активности: заказы + события + отзывы
SELECT
    c.name,
    'order'     AS event_type,
    o.order_date AS event_date,
    o.total_amount::text AS details
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id

UNION ALL

SELECT
    c.name,
    'review'    AS event_type,
    r.created_at::date AS event_date,
    r.rating::text || ' звёзд' AS details
FROM reviews r
JOIN customers c ON r.customer_id = c.customer_id

UNION ALL

SELECT
    c.name,
    e.event_type,
    e.event_date::date,
    e.page_url AS details
FROM events e
JOIN customers c ON e.customer_id = c.customer_id

ORDER BY name, event_date DESC
LIMIT 10;
name event_type event_date details
Алексей Смирнов order 2025-01-15 45990.00
Алексей Смирнов review 2024-12-20 5 звёзд
Алексей Смирнов page_view 2024-12-19 /products/1

UNION ALL — по умолчанию

Всегда начинай с UNION ALL. Используй UNION только если действительно нужно убрать дубликаты. Дедупликация стоит сортировки — это дорого на больших объёмах.

ETL-паттерн: incremental load

SQL
-- Загружаем новые записи, потом обновлённые — UNION ALL склеит
SELECT * FROM staging_orders WHERE is_new = true
UNION ALL
SELECT * FROM staging_orders WHERE is_updated = true;

UNION: объединить и убрать дубликаты

UNION = UNION ALL + DISTINCT. PostgreSQL сортирует результат для удаления дубликатов.

SQL
-- Все города, где есть клиенты ИЛИ доставка
SELECT city FROM customers
UNION
SELECT delivery_city FROM shipping_addresses;

Когда UNION полезен

SQL
-- Все уникальные клиенты, которые делали заказ ИЛИ оставляли отзыв
SELECT customer_id FROM orders
UNION
SELECT customer_id FROM reviews;

Без UNIONALL) клиент с 5 заказами и 3 отзывами появился бы 8 раз.

UNION ALL + DISTINCT vs UNION

SQL
-- Эквивалентные запросы:
SELECT city FROM customers UNION SELECT city FROM employees;

SELECT DISTINCT city FROM (
    SELECT city FROM customers
    UNION ALL
    SELECT city FROM employees
) AS combined;

Первый вариант короче, но второй даёт больше контроля (можно добавить фильтры до дедупликации).


INTERSECT: только общие строки

INTERSECT оставляет строки, присутствующие в обоих наборах.

Клиенты, которые и заказывали, и оставляли отзывы

SQL
SELECT customer_id FROM orders
INTERSECT
SELECT customer_id FROM reviews;

Города, где есть и клиенты, и сотрудники

SQL
SELECT city FROM customers
INTERSECT
SELECT city FROM employees;

INTERSECT ALL

INTERSECT ALL сохраняет дубликаты с учётом кратности. Если значение X встречается 3 раза в первом наборе и 2 раза во втором — INTERSECT ALL вернёт X дважды. На практике используется редко.


EXCEPT: вычитание наборов

EXCEPT возвращает строки из первого набора, которых нет во втором. Порядок запросов важен!

Клиенты без отзывов

SQL
-- Кто заказывал, но ни разу не оставил отзыв
SELECT customer_id FROM orders
EXCEPT
SELECT customer_id FROM reviews;

Товары, которые ни разу не покупали

SQL
SELECT product_id FROM products
EXCEPT
SELECT product_id FROM order_items;

EXCEPT vs NOT EXISTS

SQL
-- EXCEPT: убирает дубликаты автоматически
SELECT customer_id FROM orders
EXCEPT
SELECT customer_id FROM reviews;

-- NOT EXISTS: сохраняет дубликаты, гибче для сложных условий
SELECT DISTINCT o.customer_id
FROM orders o
WHERE NOT EXISTS (
    SELECT 1 FROM reviews r WHERE r.customer_id = o.customer_id
);
Подход Дубликаты NULL Гибкость
EXCEPT Убирает автоматически Корректно Только по всем колонкам
NOT EXISTS Нужен DISTINCT Корректно Любые условия связи
NOT IN Нужен DISTINCT Ловушка с NULL! Только по одной колонке

NOT IN + NULL

Если подзапрос в NOT IN возвращает хотя бы один NULL, результат всегда пуст. Используй NOT EXISTS или EXCEPT.


Сверка данных: ETL-паттерн

Одна из главных задач DE — сверить данные между витринами. EXCEPT идеален для этого:

SQL
-- Строки, которые есть в OLTP (orders), но отсутствуют в DWH (fact_sales)
SELECT order_id FROM orders WHERE status = 'delivered'
EXCEPT
SELECT order_id FROM fact_sales;

-- Строки, которые есть в DWH, но отсутствуют в OLTP (осиротевшие факты)
SELECT order_id FROM fact_sales
EXCEPT
SELECT order_id FROM orders WHERE status = 'delivered';

Для полной сверки — оба запроса через UNION ALL:

SQL
SELECT 'missing_in_dwh' AS issue, order_id FROM (
    SELECT order_id FROM orders WHERE status = 'delivered'
    EXCEPT
    SELECT order_id FROM fact_sales
) AS missing

UNION ALL

SELECT 'orphaned_in_dwh' AS issue, order_id FROM (
    SELECT order_id FROM fact_sales
    EXCEPT
    SELECT order_id FROM orders WHERE status = 'delivered'
) AS orphaned;

Комбинирование нескольких операций

Операции можно цепочить. Приоритет: INTERSECT > UNION = EXCEPT. Используй скобки для явности:

SQL
-- Клиенты, которые заказывали И оставляли отзыв, НО не отменяли заказы
(
    SELECT customer_id FROM orders WHERE status = 'delivered'
    INTERSECT
    SELECT customer_id FROM reviews
)
EXCEPT
SELECT customer_id FROM orders WHERE status = 'cancelled';

ORDER BY и LIMIT с операциями над наборами

ORDER BY и LIMIT применяются ко всему результату и пишутся один раз в конце:

SQL
SELECT name, 'customer' AS type FROM customers
UNION ALL
SELECT name, 'employee' AS type FROM employees
ORDER BY name        -- ← сортирует весь объединённый результат
LIMIT 10;            -- ← ограничивает весь результат

Если нужно отсортировать или ограничить отдельный запрос — оберни его в подзапрос:

SQL
(SELECT name FROM customers ORDER BY name LIMIT 5)
UNION ALL
(SELECT name FROM employees ORDER BY name LIMIT 5);

Производительность

Оператор Стоимость Почему
UNION ALL Минимальная Просто конкатенация
UNION Средняя Сортировка/хеш для дедупликации
INTERSECT Средняя Сортировка/хеш для поиска пересечений
EXCEPT Средняя Сортировка/хеш для поиска разницы

EXPLAIN ANALYZE

PostgreSQL может использовать HashSetOp (хеш-таблица) или Sort + SetOp (сортировка) для UNION/INTERSECT/EXCEPT. На маленьких таблицах разницы нет, на больших — проверяй через EXPLAIN ANALYZE.


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

1. UNION вместо UNION ALL

SQL
-- Медленно и неправильно: теряешь дубликаты в логе событий
SELECT customer_id, event_date FROM orders
UNION     -- ← убирает дубликаты (не нужно!)
SELECT customer_id, event_date FROM events;

-- Правильно:
... UNION ALL ...

2. Разное количество колонок

SQL
-- Ошибка: первый запрос возвращает 3 колонки, второй — 2
SELECT name, email, city FROM customers
UNION ALL
SELECT name, email FROM employees;

-- Правильно: добавь NULL
SELECT name, email, city FROM customers
UNION ALL
SELECT name, email, NULL AS city FROM employees;

3. Несовместимые типы

SQL
-- Потенциальная проблема: price (numeric) vs name (text)
SELECT price FROM products
UNION ALL
SELECT name FROM customers;   -- ← ошибка типов

-- Приведи явно:
SELECT price::text FROM products
UNION ALL
SELECT name FROM customers;

Шпаргалка

Задача Оператор
Склеить данные из нескольких таблиц UNION ALL
Собрать уникальные значения из двух источников UNION
Найти общие записи INTERSECT
Найти записи, которых нет в другом наборе EXCEPT
Сверить OLTP и DWH EXCEPT в обе стороны
Единый поток событий UNION ALL + ORDER BY

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

  • UNION ALL — самый быстрый: склеивает без дедупликации. Используй по умолчанию
  • UNION = UNION ALL + DISTINCT. Дороже, но убирает дубликаты
  • INTERSECT — пересечение двух выборок (общие строки)
  • EXCEPT — вычитание: строки из первого набора, которых нет во втором
  • Все операторы требуют одинаковое количество колонок с совместимыми типами
  • ORDER BY / LIMIT — один раз в конце, применяются ко всему результату
  • EXCEPT — идеален для сверки данных между витринами (ETL)
  • NOT IN + NULL = пустой результат. Используй EXCEPT или NOT EXISTS
  • Приоритет: INTERSECT выполняется раньше UNION / EXCEPT

Попробуй сам


Проверь себя


Что дальше?

Теперь ты умеешь комбинировать выборки. Следующий шаг — научиться безопасно изменять данные и работать с транзакциями. А если нужно вернуться к аналитике — перечитай оконные функции.


Источники