Все материалы
Продуктовая аналитикагайдсредний

UNION и UNION ALL в SQL: где объединение незаметно теряет строки

Чем UNION отличается от UNION ALL: неявный DISTINCT по всей строке, поведение NULL, цена сортировки, порядок и типы колонок, и как объединить события web и app, не потеряв больше половины строк.

КейсПрактика29 июля 2026 г.обновлено 2 октября 2026 г.17 мин

Отчёт по событиям за июль собирали из двух источников: web_events и app_events. По отдельности — 6 929 и 5 823 строки, вместе должно получиться 12 752. В отчёте оказалась 5 001 строка. Никто ничего не фильтровал и не терял: в SELECT стояли user_id и event_name, между двумя SELECT — UNION вместо UNION ALL, и 7 751 событие ушло как дубликат. Разница между операторами — одно слово, и меняет оно не скорость, а ответ на вопрос «сколько у нас было событий». Числа — из учебной базы симулятора: web здесь — июльские события пользователей с компьютера, app — с телефона.

Коротко

UNION ALL складывает два набора строк. UNION складывает и затем убирает повторы, сравнивая строки целиком — по всем колонкам, которые вы вывели в SELECT. Для справочников это то, что нужно. Для потока фактов это тихая потеря данных.

  • Дедупликация идёт по списку колонок результата, а не по ключу события.
  • Добавили event_id — повторов больше нет, UNION стал дорогим UNION ALL; source отключает сравнение только между источниками.
  • В UNION две строки с NULL в одной колонке считаются одинаковыми, хотя JOIN по этой колонке их бы не связал.
  • Колонки сопоставляются по позициям; имена берутся из первого SELECT.
  • UNION блокирующий: он должен собрать весь результат прежде, чем отдать первую строку.
  • Если вам нужны дополнительные колонки, а не дополнительные строки — это задача для JOIN.

UNION — это UNION ALL, после которого стоит DISTINCT

Механика простая: база объединяет оба набора, затем выбрасывает строки-дубликаты. Две записи ниже дают одинаковый результат — по 5 001 строке, — и вторая честнее показывает, что происходит.

Важно, что DISTINCT здесь относится ко всей строке, а не к первой колонке и не к ключу. Две строки считаются одной, если совпали все позиции: здесь это user_id и event_name одновременно. Достаточно одного различия в любой колонке, чтобы обе строки остались.

PostgreSQL разрешает написать UNION DISTINCT — это тот же UNION, только намерение видно в тексте запроса. Если дедупликация нужна осознанно, лучше писать длинную форму: через полгода никто не будет гадать, был ли ALL забыт или убран нарочно.

Две записи одного и того же поведения
with web_events as (   -- учебная вставка: web — июльские события пользователей с компьютера
  select e.event_id, e.user_id, e.event_name, e.event_time
  from events as e
  join users as u on u.user_id = e.user_id
  where u.device = 'desktop'
    and e.event_time >= date '2026-07-01' and e.event_time < date '2026-08-01'
),
app_events as (        -- учебная вставка: app — июльские события пользователей с телефона
  select e.event_id, e.user_id, e.event_name, e.event_time
  from events as e
  join users as u on u.user_id = e.user_id
  where u.device = 'mobile'
    and e.event_time >= date '2026-07-01' and e.event_time < date '2026-08-01'
)
select
  -- короткая форма: 5001 строка
  (select count(*) from (
    select user_id, event_name from web_events
    union
    select user_id, event_name from app_events
  ) as short_form) as rows_union,
  -- то же самое, но видно, что происходит: те же 5001
  (select count(*) from (
    select distinct * from (
      select user_id, event_name from web_events
      union all
      select user_id, event_name from app_events
    ) as merged
  ) as long_form) as rows_distinct;

Ключ дедупликации — это список колонок, а не бизнес-ключ

База не знает, что уникальность события задаётся event_id. Она сравнивает ровно то, что попало в SELECT. Поэтому один и тот же UNION над одними и теми же таблицами отвечает по-разному в зависимости от того, какие колонки вы решили показать.

Это главная причина, по которой UNION опасен для событий. Время в большинстве схем хранится с точностью до секунды, а event_name берётся из короткого словаря. Пользователь, который за секунду дважды открыл карточку товара, даёт две полностью идентичные строки — и UNION склеит их в одну, хотя в продукте это два разных действия. В учебной базе таких совпадений нет: одно и то же событие случается у пользователя не чаще раза в день, и с event_time в SELECT все 12 752 строки на месте. Потери начинаются без времени: все июльские app_open одного человека становятся одной строкой.

Один UNION, разные ответы. Числа — из первого запроса статьи с другим списком колонок
Что в SELECTЧто считается дубликатомРезультат
user_idлюбые два события одного пользователясписок уникальных пользователей: 2 688 строк
user_id, event_nameповторы одного действиясписок пар «кто что делал»: 5 001 строка
user_id, event_name, event_timeсовпадение с точностью до единицы времени: секунды, в учебной базе — минутыпоток фактов с потерями, в учебной базе — без них: 12 752 строки
+ event_idничегополный поток, 12 752 строки, но с лишней сортировкой

Колонка source незаметно отключает дедупликацию

Почти в любом гайде по объединению событий советуют добавить колонку-источник, и совет правильный: без неё в отчёте не отличить web от app. Но посмотрите, что она делает с UNION. В левой ветке это константа 'web', в правой — 'app'. Значит, ни одна строка из web никогда не совпадёт со строкой из app: они различаются в четвёртой колонке.

После добавления source межтабличная дедупликация выключается полностью, а внутритабличная остаётся: два одинаковых web-события всё ещё склеятся между собой. То есть запрос сохранил UNION, потерял половину своего эффекта и целиком сохранил его стоимость. Это худший из возможных вариантов — непредсказуемое поведение за полную цену. На учебной базе пользователь целиком в одном источнике, и UNION с колонкой source возвращает ту же 5 001 строку. Раздайте те же события иначе — нечётные event_id в web, чётные в app, — и человек окажется в обоих источниках: без source выйдет та же 5 001 строка, с source — 6 920.

Обратная сторона того же правила: если вы уберёте source из SELECT, чтобы посчитать «всего событий», дедупликация включится обратно и снова начнёт резать строки. Добавление и удаление колонки здесь меняет не оформление, а число в отчёте.

Для потока фактов нужен UNION ALL и явный источник
with web_events as (   -- учебная вставка: web — июльские события пользователей с компьютера
  select e.event_id, e.user_id, e.event_name, e.event_time
  from events as e
  join users as u on u.user_id = e.user_id
  where u.device = 'desktop'
    and e.event_time >= date '2026-07-01' and e.event_time < date '2026-08-01'
),
app_events as (        -- учебная вставка: app — июльские события пользователей с телефона
  select e.event_id, e.user_id, e.event_name, e.event_time
  from events as e
  join users as u on u.user_id = e.user_id
  where u.device = 'mobile'
    and e.event_time >= date '2026-07-01' and e.event_time < date '2026-08-01'
)
select source, count(*) as rows_count   -- app: 5823, web: 6929, вместе 12752
from (
  select user_id, event_name, event_time, 'web' as source
  from web_events
  union all
  select user_id, event_name, event_time, 'app' as source
  from app_events
) as merged
group by source
order by source;
Проверка на месте

Посмотрите на список колонок в SELECT и спросите: если две строки совпадут по всем этим полям, это точно одно и то же событие? Если ответ «не обязательно» — нужен UNION ALL, а уникальность проверяется отдельным запросом по ключу.

NULL внутри UNION сравнивается не так, как в WHERE

Про NULL обычно запоминают одно правило: NULL = NULL не истина, а неизвестность. Поэтому WHERE a = b не найдёт пару из двух пустых значений, и JOIN ... ON a.country = b.country не свяжет строки, где страна не заполнена с обеих сторон.

Операции над множествами работают иначе. Для UNION, EXCEPT, INTERSECT, а также для DISTINCT и GROUP BY две строки — дубликаты, если их колонки «не различаются». NULL в этой логике равен NULL. Строки (17, 'app_open', NULL) и (17, 'app_open', NULL) для UNION одинаковы и превратятся в одну.

На практике это бьёт по колонкам, которые заполнены не всегда. В учебной базе у 55 пользователей пустая country: у 28 с компьютера и у 27 с телефона. UNION списков стран двух сегментов возвращает пять строк — четыре страны и одну с NULL, куда свернулись все пустые значения. Та же колонка в условии JOIN дала бы по этим строкам ноль совпадений. Одна и та же пара NULL ведёт себя противоположным образом в двух соседних конструкциях языка.

Сравнение, которым UNION склеивает строки
select
  -- NULL, а не true: обычное равенство не работает с NULL
  null = null as eq,
  -- true: именно так set-операции сравнивают колонки
  null is not distinct from null as same_for_union,
  -- 5: четыре страны и одна строка NULL на 55 пустых значений
  (select count(*) from (
    select country from users where device = 'desktop'
    union
    select country from users where device = 'mobile'
  ) as countries) as union_rows,
  -- 0: JOIN по той же колонке пустые значения не связал
  (select count(*)
   from users as a
   join users as b on b.country = a.country
   where a.country is null) as join_rows;

Сколько строк съел UNION — это считается, а не угадывается

Если в отчёте 5 001 вместо 12 752, не нужно спорить о причинах: разница измеряется одним запросом. Соберите объединение дважды и сравните количество строк. Дальше сравните count(*) с count(distinct event_id) — это покажет, есть ли в потоке реальные технические дубли, которые надо убирать на загрузке, а не на чтении. Здесь их нет: real_events равен rows_union_all, то есть потеряны настоящие события.

В нашем случае 7 751 строка — это 61% июльского потока. Если бы этот отчёт ушёл в дашборд DAU или в воронку, все конверсии на нём считались бы от заниженного знаменателя, и расхождение с продуктовыми цифрами искали бы в атрибуции.

Что происходит с июльским потоком событий

Web и App — размеры источников в учебной базе. UNION ALL равен их сумме: 6 929 + 5 823 = 12 752. UNION оставляет 5 001 строку, то есть теряет 7 751 строку, совпавшую по двум выведенным колонкам: user_id и event_name.

Строк в результате
Три числа, которые объясняют расхождение
with web_events as (   -- учебная вставка: web — июльские события пользователей с компьютера
  select e.event_id, e.user_id, e.event_name, e.event_time
  from events as e
  join users as u on u.user_id = e.user_id
  where u.device = 'desktop'
    and e.event_time >= date '2026-07-01' and e.event_time < date '2026-08-01'
),
app_events as (        -- учебная вставка: app — июльские события пользователей с телефона
  select e.event_id, e.user_id, e.event_name, e.event_time
  from events as e
  join users as u on u.user_id = e.user_id
  where u.device = 'mobile'
    and e.event_time >= date '2026-07-01' and e.event_time < date '2026-08-01'
),
merged as (
  select user_id, event_name, event_id from web_events
  union all
  select user_id, event_name, event_id from app_events
)
select
  count(*)                               as rows_union_all,   -- 12752
  count(distinct (user_id, event_name))  as rows_union,       -- 5001
  count(distinct event_id)               as real_events       -- 12752
from merged;

Посмотрите на сами группы, а не только на разницу

Число потерянных строк говорит, что проблема есть, но не говорит, какая. Сгруппируйте объединённый набор по тем же колонкам и вытащите десяток самых крупных групп. По ним сразу видно природу повторов — и решение у каждой природы своё.

На учебной базе верхние группы — app_open одного пользователя в разные дни июля: 14 строк у пользователя 1055, по 12 у пяти следующих. Повторы легитимные, нужен UNION ALL. Окажись в группе одна и та же оплата с одинаковым payment_id, это был бы дубль на загрузке: его чистят в пайплайне по ключу, а не неявным DISTINCT в отчёте. Если в группе строки из обоих источников (sources = 2) и в SELECT есть время, события дублируются между web и app, и вопрос уже про трекинг, а не про SQL. Здесь таких групп нет.

Верхние группы повторов
with web_events as (   -- учебная вставка: web — июльские события пользователей с компьютера
  select e.event_id, e.user_id, e.event_name, e.event_time
  from events as e
  join users as u on u.user_id = e.user_id
  where u.device = 'desktop'
    and e.event_time >= date '2026-07-01' and e.event_time < date '2026-08-01'
),
app_events as (        -- учебная вставка: app — июльские события пользователей с телефона
  select e.event_id, e.user_id, e.event_name, e.event_time
  from events as e
  join users as u on u.user_id = e.user_id
  where u.device = 'mobile'
    and e.event_time >= date '2026-07-01' and e.event_time < date '2026-08-01'
),
merged as (
  select user_id, event_name, 'web' as source from web_events
  union all
  select user_id, event_name, 'app' as source from app_events
)
select
  user_id, event_name,
  count(*)                  as rows_in_group,
  count(distinct source)    as sources
from merged
group by user_id, event_name
having count(*) > 1
order by rows_in_group desc, user_id, event_name
limit 10;

Цена дедупликации видна в плане запроса

UNION ALL в плане превращается в Append: строки из ветвей просто текут наружу одна за другой, память почти не тратится, первую строку можно отдать сразу. UNION добавляет сверху шаг дедупликации — HashAggregate или Sort плюс Unique, в зависимости от версии и объёма. Этот шаг блокирующий: PostgreSQL сначала собирает все строки в хеш-таблицу или сортирует их и только потом отдаёт первую.

Из блокирующей природы следует неприятное свойство: limit 100 поверх UNION не экономит почти ничего. База всё равно обязана прогнать через дедупликацию весь набор. На двенадцати тысячах строк это незаметно, но уже на 160 000 сгенерированных строк из блока ниже хеш-таблица при work_mem по умолчанию, 4 МБ, не помещается в память и уезжает во временные файлы: в плане Batches: 5 и ненулевой Disk Usage.

Есть и жёсткое ограничение. Чтобы убрать дубликаты, база должна уметь сравнивать каждую колонку на равенство. У типа json в PostgreSQL оператора равенства нет, поэтому UNION с json-колонкой просто падает с ошибкой «could not identify an equality operator». UNION ALL с той же колонкой работает, потому что ничего не сравнивает. Для свойств событий это типичный случай: либо ALL, либо jsonb, у которого равенство есть.

sqlPostgreSQL only: что искать в выводе EXPLAIN. Песочница EXPLAIN не выполняет — блок для своего PostgreSQL
explain (analyze, buffers)
select g as user_id, g % 5 as event_code from generate_series(1, 80000) as g
union
select g, g % 5 from generate_series(60001, 140000) as g;
-- вывод PostgreSQL 14 при work_mem = 4MB; с версии 15 хеш-таблице по умолчанию
-- разрешено вдвое больше памяти (hash_mem_multiplier = 2), Memory Usage и Disk Usage будут другими
-- HashAggregate (rows=140000)  шаг дедупликации: на входе 160 000 строк, на выходе 140 000
--   Batches: 5  Memory Usage: 4145kB  Disk Usage: 3640kB
--   ->  Append (rows=160000)   всё, что ниже этой строки, и есть план UNION ALL
--         ->  Function Scan on generate_series g
--         ->  Function Scan on generate_series g_1
-- смотрите Batches и Disk Usage: если Batches больше одного, а Disk Usage не нулевой, дедупликация ушла на диск

Колонки сопоставляются по позициям, а не по именам

Это правило ломает больше отчётов, чем выбор между UNION и UNION ALL. Ветви объединяются по номерам колонок. Имена результата берутся из первого SELECT, имена во втором не значат вообще ничего — они нужны только читателю.

Поэтому перепутанные местами колонки совместимых типов не вызывают никакой ошибки. Если в одной ветке порядок channel, country, а в другой country, channel, запрос выполнится и вернёт набор, в котором organic лежит в колонке country. Диагностика потом уходит в дашборд, а не в запрос: в фильтре по стране появляются каналы.

С несовместимыми типами PostgreSQL, наоборот, шумит честно: text и integer он объединять не станет. DuckDB, на котором работает песочница, молча приведёт число к тексту. Тихие случаи — там, где приведение возможно. numeric(12,2) и double precision сведутся к общему типу, и копейки округлятся по правилам float. timestamp и timestamptz сведутся с подстановкой таймзоны сессии, и граница суток съедет. Поэтому типы в ветвях лучше приводить руками.

Что делает база при несовпадении ветвей
СитуацияПоведениеЧем это грозит
разное число колонокошибка при парсингевидно сразу, ловится тестом
text и integer на одной позицииPostgreSQL: ошибка, типы не сводятся. DuckDB: число становится текстомв PostgreSQL видно сразу, в DuckDB — нет
перепутан порядок совместимых колонокошибки нет, результат неверныйзначения лежат в чужих полях
numeric и double precisionсведение к общему типуокругление денег
timestamp и timestamptzсведение с таймзоной сессиисдвиг границ суток
select * в обеих веткахконтракт зависит от DDLновая колонка ломает отчёт
Почему SELECT * здесь особенно плох

При select * порядок и состав колонок задаёт схема таблицы. Кто-то добавит колонку в web_events — и ветви перестанут совпадать либо совпадут по позициям неправильно. Перечисляйте колонки явно и в одном порядке в обеих ветвях.

ORDER BY у объединения один и стоит в конце

Сортировка применяется ко всему результату объединения, а не к последней ветке, и ссылаться она может только на выходные колонки или их номера. Выражение, которое существовало внутри одной ветки, во внешнем ORDER BY недоступно — нужен алиас в SELECT.

Если сортировка или limit нужны внутри ветки — например, взять по пять последних событий из каждого источника, — ветку заворачивают в скобки или в подзапрос. Без этого limit прилипнет ко всему объединению, и вы получите пять строк из того источника, который оказался первым в плане.

Ограничение внутри ветки и сортировка снаружи
with web_events as (   -- учебная вставка: web — июльские события пользователей с компьютера
  select e.event_id, e.user_id, e.event_name, e.event_time
  from events as e
  join users as u on u.user_id = e.user_id
  where u.device = 'desktop'
    and e.event_time >= date '2026-07-01' and e.event_time < date '2026-08-01'
),
app_events as (        -- учебная вставка: app — июльские события пользователей с телефона
  select e.event_id, e.user_id, e.event_name, e.event_time
  from events as e
  join users as u on u.user_id = e.user_id
  where u.device = 'mobile'
    and e.event_time >= date '2026-07-01' and e.event_time < date '2026-08-01'
)
(
  select user_id, event_time, 'web' as source
  from web_events order by event_time desc, user_id limit 5
)
union all
(
  select user_id, event_time, 'app' as source
  from app_events order by event_time desc, user_id limit 5
)
order by event_time desc, source, user_id;

Архив и текущая таблица: UNION как заглушка вместо границы

Второй по частоте сценарий после склейки источников — «старые данные в архиве, новые в текущей таблице». Здесь ошибка обратная: не потеря строк, а их удвоение. В паре, собранной из payments, архив залит по 31 июля включительно (609 оплат), в текущей таблице записи с 25 июля (781), и UNION ALL без границы считает неделю дважды: 1 390 строк вместо 1 251.

Соблазн поставить UNION вместо UNION ALL и считать вопрос закрытым понятен, но он не решает задачу. Дедупликация спасёт только при полном совпадении всех выведенных колонок. Стоит в архиве лежать другому updated_at или пересчитанному статусу — и строка останется в обоих экземплярах, уже с разными значениями. Отчёт будет содержать два варианта одной оплаты, и какой из них попадёт в сумму, зависит от того, по каким полям вы потом группируете. На нашей паре UNION даст верные 1 251, но лишь потому, что копии совпадают до последней колонки.

Рабочее решение — явная полуоткрытая граница: архив до даты переключения, текущая таблица от неё. Границу стоит держать в одном месте (параметр, CTE, настройка модели), а не повторять в двух where, которые со временем разъедутся. После сборки полезно посчитать строки по дням и убедиться, что в день перехода нет ни провала, ни горба.

Граница вместо надежды на дедупликацию
with payments_archive as (   -- учебная вставка: архив залит по 31 июля включительно
  select payment_id, paid_at, amount from payments where paid_at < date '2026-08-01'
),
payments_live as (           -- учебная вставка: в текущей таблице записи с 25 июля
  select payment_id, paid_at, amount from payments where paid_at >= date '2026-07-25'
),
cutover as (select date '2026-08-01' as switch_at),
merged as (
  select payment_id, paid_at, amount, 'archive' as layer
  from payments_archive, cutover
  where paid_at < cutover.switch_at
  union all
  select payment_id, paid_at, amount, 'live' as layer
  from payments_live, cutover
  where paid_at >= cutover.switch_at
)
-- archive: 609 строк, live: 642, вместе 1251 — столько же, сколько в payments
select layer, count(*) as rows_count, min(paid_at) as first_day, max(paid_at) as last_day
from merged
group by layer
order by layer;

Когда UNION по делу

UNION не вреден — у него просто узкая область применения. Она начинается там, где дубликат действительно означает одну сущность, а не один факт. Собрать единый список кодов регионов из двух справочников, свести перечень категорий из старой и новой таксономии, получить список пользователей, которые встречаются хотя бы в одном из двух сегментов, — во всех случаях повторы не несут информации, и убрать их правильно. На учебной базе user_id из payments и subscriptions через UNION дают 912 платящих, через UNION ALL — 2 163 строки.

Отличие от потока фактов формулируется одной фразой: в справочнике строка — это описание объекта, в событиях строка — это отдельное происшествие. Два одинаковых описания избыточны, два одинаковых происшествия — нет. Если внутри одной задачи нужны и то и другое, собирайте факты через UNION ALL, а уникальность считайте явно — через count(distinct ...) или отдельный слой.

  • Справочники и словари, где нужен уникальный перечень значений.
  • Сведение двух таксономий в один список.
  • Набор сущностей «встречается хотя бы в одном источнике».
  • Небольшие наборы, где стоимость дедупликации не имеет значения.

UNION или JOIN — это вопрос о форме ответа

Синтаксически эти операторы не похожи, но на практике их всё равно путают, потому что оба «объединяют таблицы». Разница в том, что меняется в результате. UNION добавляет строки: набор колонок остаётся тем же, растёт высота. JOIN добавляет колонки: число сущностей в идеале не меняется, растёт ширина.

Отсюда и правило выбора. Если два источника содержат факты одного вида и вам нужно сложить их в один поток — это UNION ALL. Если один источник описывает сущность, а второй добавляет к ней признаки, — это JOIN. А если вопрос звучит как «кто есть в A и отсутствует в B», ни тот, ни другой: нужен EXCEPT или anti-join через NOT EXISTS.

Что выбрать под задачу
ЗадачаОператорПочему
события web и app в одном потокеUNION ALLповторы могут быть реальными фактами
архив плюс текущая таблицаUNION ALL с явной границейчтобы не посчитать период дважды
уникальный список кодов из двух справочниковUNIONдубликат не несёт информации
к пользователям добавить выручкуJOINнужны колонки, а не строки
кто есть в A, но не в BEXCEPT или NOT EXISTSэто разность множеств

EXCEPT и INTERSECT наследуют ту же дедупликацию

Два соседних оператора ведут себя по тому же принципу, и про это забывают чаще, чем про UNION. EXCEPT возвращает строки первого набора, которых нет во втором, и заодно убирает повторы внутри результата. INTERSECT оставляет общие строки — тоже без повторов. Кратность теряется в обоих случаях.

Если кратность важна, в PostgreSQL есть EXCEPT ALL и INTERSECT ALL: они считают повторы по правилам мультимножеств, то есть три строки минус одна дают две. Для сверки двух выгрузок «построчно, с учётом количества» нужна именно форма с ALL, иначе расхождение в дублях исчезнет из отчёта вместе с самими дублями.

Что проверить перед тем, как отдать запрос

Объединение почти никогда не падает с ошибкой — оно возвращает не то число. Поэтому проверки здесь арифметические: вы сравниваете то, что получил, с тем, что обязано получиться по определению.

  • Сумма строк по источникам совпадает с count(*) результата — для UNION ALL это тождество, и оно должно выполняться.
  • Разница между UNION ALL и UNION посчитана и объяснена, а не обнаружена задним числом.
  • Колонки в ветвях перечислены явно, в одном порядке, с одинаковыми типами и приведениями.
  • По каждой колонке понятно, означает ли совпадение значений совпадение факта.
  • В колонках, где часто встречается NULL, учтено, что UNION считает пустые значения одинаковыми.
  • Периоды источников не пересекаются, либо пересечение обрезано явным условием.
  • Число строк по дням не имеет провалов и горбов на стыке источников.

Практика: соберите один набор двумя способами

Возьмите те же web- и app-события, но за август, и соберите их двумя способами: через UNION ALL и через UNION с тем же списком колонок. Запишите оба count(*) и разницу между ними: должно получиться 16 571 и 6 803 строки, разница — 9 768. Затем добавьте в SELECT колонку source и пересчитайте UNION ещё раз — на этих источниках число не изменится, и вы сможете объяснить, почему именно.

Дальше вытащите десять самых крупных групп повторов и определите по ним, какие строки дедупликация считала одинаковыми. Для каждой группы ответьте одним словом, что это: нормальное поведение пользователя, дубль загрузки или двойной трекинг. В финальной записке укажите, какой ключ отвечает за уникальность события и на каком слое — загрузка, модель или отчёт — должна происходить его дедупликация.

Итог и куда дальше

Для потоков фактов по умолчанию ставьте UNION ALL и добавляйте колонку-источник: повторы в событиях чаще настоящие, чем ошибочные. UNION оставьте для справочников и списков значений, где дубликат действительно лишний, и пишите его форму явно. Если строк после объединения оказалось меньше ожидаемого, это не особенность оператора, а ваш следующий вопрос к данным.

Уникальность при этом никуда не уходит — она просто перестаёт быть побочным эффектом оператора объединения. Её определяют ключом события и проверяют отдельным запросом, и именно такая проверка потом объясняет расхождения в дашборде.

Продолжить чтение
Вся библиотека
Продуктовая аналитика30 июля 2026 г.19 мин
Три вложенных прямоугольника, внутренний закрашен.

Подзапросы в SQL: IN, EXISTS, подзапрос в FROM и когда нужен JOIN

Подзапросы в SQL на реальной учебной базе: IN и EXISTS вместо JOIN, почему NOT IN возвращает ноль строк из-за NULL, подзапрос в FROM для смены зерна, скалярный и коррелированный подзапрос, подзапрос в HAVING и правило выбора между подзапросом, JOIN и CTE.

Читать материал