Коротко: UNION и UNION ALL оба склеивают результаты двух запросов вертикально — строки одного идут под строками другого. Разница одна, но важная: UNION убирает дубликаты (неявный DISTINCT по всей строке), а UNION ALL отдаёт всё как есть. Из-за дедупликации UNION вынужден отсортировать или захэшировать весь результат, поэтому он медленнее и иногда молча теряет строки, которые вы считали разными. Если дубликатов заведомо нет или они вам нужны — берите UNION ALL.
Эта на первый взгляд косметическая деталь стоит аналитикам испорченных отчётов: посчитали заказы из двух источников через UNION, а суммарное число оказалось меньше, чем сумма по каждому источнику. Разберём, почему так происходит, когда это опасно и как проверить себя.
Что делают UNION и UNION ALL и в чём ключевая разница?
Оба оператора относятся к вертикальному объединению (set operators). В отличие от JOIN, который прикладывает колонки одной таблицы сбоку к другой, UNION ставит строки одного запроса под строки другого. Правило простое: количество и порядок колонок в обоих запросах должны совпадать, а результат — это одна общая выборка.
Ключевое отличие сформулируем одной фразой: UNION — это UNION ALL плюс неявный DISTINCT по всем колонкам.
Возьмём два лога событий. В первом — клики из веб-приложения, во втором — из мобильного. Хотим один общий поток.
-- Просто клеим всё подряд, ничего не выкидываем
SELECT user_id, event_ts, 'web' AS source
FROM web_events
UNION ALL
SELECT user_id, event_ts, 'mobile' AS source
FROM mobile_events;
Если в обоих логах встретится физически одинаковая строка (те же user_id, event_ts и source), UNION ALL вернёт обе, а UNION схлопнет их в одну. Вот та же выборка через UNION:
SELECT user_id, event_ts, 'web' AS source
FROM web_events
UNION
SELECT user_id, event_ts, 'mobile' AS source
FROM mobile_events;
Здесь база пройдётся по всему объединённому результату и оставит только уникальные строки. Строка считается дубликатом, только если совпадают значения во всех колонках сразу.
Почему UNION медленнее: как работает неявный DISTINCT?
Чтобы убрать дубли, СУБД должна сравнить каждую строку с каждой. Наивное попарное сравнение — это квадрат от числа строк, поэтому планировщик так не делает. Он выбирает один из двух способов:
- Sort + Unique — отсортировать весь результат и выкинуть подряд идущие одинаковые строки. Стоимость примерно
N * log(N)плюс возможный сброс на диск, если данные не влезают вwork_mem. - Hash Aggregate — построить хэш-таблицу по всей строке и держать только уникальные ключи. Быстрее сортировки, но требует памяти под всю таблицу уникальных значений.
В любом случае UNION дополнительно к чтению обеих выборок прогоняет их через дедупликацию. UNION ALL не делает ничего лишнего — он просто отдаёт строки потоком, поток за потоком, без буферизации и сортировки. Отсюда правило: если вам не нужна дедупликация, каждый UNION — это налог на пустом месте.
Увидеть эту работу можно в плане запроса. Если вы ещё не читали планы, загляните в разбор EXPLAIN ANALYZE в PostgreSQL — там подробно про то, как отличить сортировку от хэша и заметить сброс на диск.
EXPLAIN ANALYZE
SELECT user_id FROM web_events
UNION
SELECT user_id FROM mobile_events;
-- В плане увидите узел HashAggregate или Unique поверх Append.
-- У UNION ALL останется только Append — без агрегации.
Какие требования к колонкам: количество, порядок, типы?
Три правила, которые ловят новичков:
- Одинаковое число колонок. Оба
SELECTдолжны вернуть ровно столько же колонок.SELECT a, bнельзя объединить сSELECT a, b, c— база сразу выдаст ошибку. - Совпадение по порядку, а не по имени. Колонки сопоставляются по позиции. Если в первом запросе первая колонка — это
user_id, а во втором на первом местеevent_ts, база молча сольёт разнородные значения в один столбец. Ошибки не будет — будет каша. - Совместимые типы по позициям. Первая колонка первого запроса должна быть приводима к типу первой колонки второго.
INTEGERиNUMERICуживутся, аINTEGERиDATEдадут ошибку приведения типов.
Имя итоговой колонки берётся из первого запроса — псевдонимы во втором запросе игнорируются. Про приведение типов и CAST есть отдельный разбор NULL, типы данных, COALESCE и CAST, это пригодится, когда типы в источниках расходятся.
Типичная ошибка — перепутанный порядок:
-- НЕВЕРНО: во втором запросе колонки переставлены местами.
-- Ошибки не будет, но amount и user_id перемешаются.
SELECT user_id, amount FROM orders_ru
UNION ALL
SELECT amount, user_id FROM orders_kz;
Явно называйте колонки в каждом SELECT в одном и том же порядке — это дешёвая страховка. SELECT * в UNION-запросах лучше не использовать: добавили колонку в одну таблицу — и запрос ломается или тихо перекашивается.
Когда UNION молча теряет строки и как это заметить?
Самый коварный сценарий. Представьте, что вы объединяете заказы из двух регионов и считаете строки:
SELECT order_id, amount FROM orders_ru
UNION
SELECT order_id, amount FROM orders_kz;
Если в обоих регионах есть заказ с одинаковыми order_id и amount (например, оба начали нумерацию с единицы, а суммы округлены до сотен), UNION схлопнет такие строки. Вы получите меньше заказов, чем есть на самом деле, и не увидите ни ошибки, ни предупреждения.
Как это диагностировать:
- Сравните счётчики. Посчитайте
COUNT(*)отдельно по каждому источнику и сложите вручную. Затем посчитайтеCOUNT(*)у объединения черезUNION. Расхождение = число схлопнутых дублей. - Проверьте оба варианта. Один и тот же запрос с
UNIONи сUNION ALL. Разница в числе строк — это ровно те строки, которыеUNIONсчитает дубликатами. - Добавьте различающую колонку. Литерал-источник (
'ru','kz') делает строки из разных таблиц заведомо уникальными, иUNIONперестаёт их схлопывать.
-- Число «потерянных» строк = сколько дублей убрал UNION
SELECT
(SELECT COUNT(*) FROM orders_ru) +
(SELECT COUNT(*) FROM orders_kz) AS total_union_all,
(SELECT COUNT(*) FROM (
SELECT order_id, amount FROM orders_ru
UNION
SELECT order_id, amount FROM orders_kz
) x) AS total_union;
Это классическая тема в подборках по антипаттернам SQL: аналитик берёт UNION по привычке, теряет часть данных и не замечает, потому что число «выглядит правдоподобно». Особенно больно, когда на этой цифре строится метрика вроде GMV или DAU — недосчёт уходит прямиком в дашборд.
Как склеить логи из двух источников через UNION ALL?
Когда вы объединяете именно логи событий, дедупликация почти всегда вредна: два реальных клика в одну и ту же миллисекунду от одного пользователя — это два события, а не одно. Поэтому для склейки логов по умолчанию берут UNION ALL и добавляют колонку-источник.
-- Единый поток событий с пометкой откуда пришло
SELECT
user_id,
event_ts,
event_name,
'web' AS source
FROM web_events
WHERE event_ts >= DATE '2026-07-01'
UNION ALL
SELECT
user_id,
event_ts,
event_name,
'mobile' AS source
FROM mobile_events
WHERE event_ts >= DATE '2026-07-01'
ORDER BY user_id, event_ts;
Обратите внимание на два момента. Во-первых, ORDER BY ставится один раз — в самом конце, он относится ко всему объединению, а не к последнему SELECT. Во-вторых, WHERE пишется в каждом запросе отдельно: фильтровать нужно до склейки, чтобы не тащить лишние строки через объединение.
Если после склейки логов нужна сессионизация или подсчёт событий подряд, дальше в ход идут оконные функции — например, для разбивки потока на сессии по разрыву во времени (подробный разбор — в статье про сессионизацию событий). А сам паттерн «склеить источники и посчитать» удобно отрабатывать на тренажёре SQL: там задачи как раз на реальных логах с дублями.
Как убирать дубли осознанно, а не вслепую?
Проблема UNION не в том, что он дедуплицирует, а в том, что он дедуплицирует по всей строке и молча. Часто вам нужно убрать дубли по конкретному ключу, а не по полному совпадению всех колонок. Тогда правильнее взять UNION ALL и дедуплицировать явно.
Сравним подходы:
| Задача | Плохо | Хорошо |
|---|---|---|
| Убрать полные дубли строк | UNION (работает, но неявно и медленно на больших данных) | UNION ALL + DISTINCT, если это осознанное намерение |
| Оставить одну строку на пользователя | UNION (не сработает — сравнивает всю строку) | UNION ALL + ROW_NUMBER() OVER (PARTITION BY user_id ...) |
| Просто склеить два потока | UNION (теряет реальные строки) | UNION ALL |
Дедупликация по ключу через оконную функцию:
-- Одна, самая свежая строка на user_id из объединённого потока
WITH merged AS (
SELECT user_id, event_ts, 'web' AS source FROM web_events
UNION ALL
SELECT user_id, event_ts, 'mobile' AS source FROM mobile_events
)
SELECT user_id, event_ts, source
FROM (
SELECT
merged.*,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY event_ts DESC) AS rn
FROM merged
) t
WHERE rn = 1;
Такой подход прозрачен: вы видите, по какому ключу и по какому правилу выбираете строку. Полный разбор способов чистки — в статье про дедупликацию данных. Правило на каждый день: UNION уместен, только когда вам действительно нужно уникальное множество строк целиком; во всех остальных случаях — UNION ALL плюс явная логика.
Что быстрее на большом объёме: мини-бенчмарк?
Соберём синтетический тест на Python: два набора по 5 млн строк с частичным пересечением, и сравним UNION против UNION ALL на SQLite в памяти.
import sqlite3, time, random
con = sqlite3.connect(":memory:")
cur = con.cursor()
cur.execute("CREATE TABLE a (uid INTEGER, val INTEGER)")
cur.execute("CREATE TABLE b (uid INTEGER, val INTEGER)")
# 5 млн строк в каждой таблице, домены значений пересекаются -> будут дубли
rows_a = [(random.randint(1, 2_000_000), random.randint(1, 100)) for _ in range(5_000_000)]
rows_b = [(random.randint(1, 2_000_000), random.randint(1, 100)) for _ in range(5_000_000)]
cur.executemany("INSERT INTO a VALUES (?, ?)", rows_a)
cur.executemany("INSERT INTO b VALUES (?, ?)", rows_b)
con.commit()
def bench(sql):
t = time.perf_counter()
n = len(cur.execute(sql).fetchall())
return round(time.perf_counter() - t, 2), n
print("UNION ALL:", bench("SELECT uid,val FROM a UNION ALL SELECT uid,val FROM b"))
print("UNION :", bench("SELECT uid,val FROM a UNION SELECT uid,val FROM b"))
Порядок результатов, который вы увидите (абсолютные числа зависят от машины):
| Оператор | Строк на выходе | Относительное время |
|---|---|---|
UNION ALL | 10 000 000 | 1x (базовая линия) |
UNION | ~1 900 000 | в 2–4 раза дольше |
Два вывода. Первый: UNION вернул почти в пять раз меньше строк — все совпадения по паре (uid, val) схлопнулись, потому что домен значений узкий. Если бы это были заказы, вы бы недосчитались миллионов. Второй: дедупликация — это не бесплатно; на честных объёмах она добавляет кратный оверхед из-за сортировки или хэширования. Прогнать похожий эксперимент можно и в браузерном Python-тренажёре, не поднимая ничего локально. Основы вертикального и горизонтального объединения таблиц, кстати, разбираются с нуля в первой части SQL-курса.
Какие вопросы про UNION задают на собеседовании?
Тема почти гарантированно всплывает на техническом скрининге аналитика. Частые формулировки и короткие правильные ответы:
- Чем отличается UNION от UNION ALL?
UNIONубирает дубликаты через неявныйDISTINCTпо всей строке и потому медленнее;UNION ALLпросто конкатенирует выборки без обработки. - Что быстрее и почему?
UNION ALL, потому что не требует сортировки или хэширования для дедупликации. - Какие требования к колонкам? Одинаковое число колонок, совместимые типы по позициям; сопоставление идёт по порядку, а не по именам; имя итоговой колонки берётся из первого запроса.
- Где ставится ORDER BY? Один раз, в самом конце — он применяется ко всему объединённому результату.
- Когда UNION опасен? Когда в источниках есть строки, случайно совпадающие по всем колонкам, — он молча их схлопнет, и вы недосчитаетесь данных.
- Как убрать дубли по одному ключу, а не по всей строке?
UNION ALLплюсROW_NUMBER()в подзапросе, а неUNION.
Отдельно любят спросить, что вернёт UNION из двух одинаковых однострочных запросов: правильный ответ — одну строку, тогда как UNION ALL вернёт две. Это быстрая проверка на понимание механики дедупликации, а не заученной формулировки, и на ней часто спотыкаются те, кто помнит только слова «UNION убирает дубли».
Больше разборов в подборке топ-50 вопросов по SQL и в общем банке вопросов на собеседование. Проговорить ответы вслух под запись, как на реальном скрининге, помогает AI-интервьюер — он задаёт уточняющие и ловит на неточных формулировках вроде «UNION соединяет таблицы» (соединяет — это про JOIN).
Итог держите в голове одной строкой: UNION ALL — по умолчанию, UNION — только когда осознанно нужна уникальность всей строки целиком. А когда нужно убрать дубли по ключу — это отдельная задача для оконной функции, а не для UNION. Пять первых задач в тренажёре как раз на этот паттерн склейки логов — попробуйте бесплатно, а полный банк из 425 SQL-задач открывается в Pro.