UNION и UNION ALL в SQL: где объединение незаметно теряет строки
Чем UNION отличается от UNION ALL: неявный DISTINCT по всей строке, поведение NULL, цена сортировки, порядок и типы колонок, и как объединить события web и app, не потеряв больше половины строк.
Содержание статьи
Отчёт по событиям за июль собирали из двух источников: 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 одного человека становятся одной строкой.
| Что в 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, чтобы посчитать «всего событий», дедупликация включится обратно и снова начнёт резать строки. Добавление и удаление колонки здесь меняет не оформление, а число в отчёте.
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 ведёт себя противоположным образом в двух соседних конструкциях языка.
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, у которого равенство есть.
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 * порядок и состав колонок задаёт схема таблицы. Кто-то добавит колонку в 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, но не в B | EXCEPT или 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 оставьте для справочников и списков значений, где дубликат действительно лишний, и пишите его форму явно. Если строк после объединения оказалось меньше ожидаемого, это не особенность оператора, а ваш следующий вопрос к данным.
Уникальность при этом никуда не уходит — она просто перестаёт быть побочным эффектом оператора объединения. Её определяют ключом события и проверяют отдельным запросом, и именно такая проверка потом объясняет расхождения в дашборде.
Материалы по теме

GROUP BY в SQL: группировка по одному и нескольким полям
Группировка в SQL на учебной базе: GROUP BY по нескольким полям, по дате и выражению, порядок выполнения, ошибка must appear in the GROUP BY clause, NULL, DISTINCT и PARTITION BY.

Операторы сравнения и логика в SQL: AND, OR, NOT и NULL
Трёхзначная логика SQL на практике: почему NOT IN возвращает пустоту, где AND незаметно съедает OR и как собрать длинный фильтр так, чтобы его можно было проверить.

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