Соединение таблиц и получение сводных показателей — ядро аналитических запросов
ON и в WHERE для внешних соединенийNULLROLLUPJOIN, IN и EXISTSНормализация разложила данные по таблицам (раздел 4). Чтобы собрать ответ на вопрос бизнеса, их нужно соединить обратно. Соединение — это декартово произведение с условием отбора.
-- Посты вместе с названием сообщества
SELECT p.title, p.score, c.name AS community
FROM posts p
JOIN communities c ON c.id = p.community_id;
p.title, а не просто title. При соединении трёх и более таблиц это единственный способ понять, откуда взялся столбец, — и единственная защита от ошибки «column reference is ambiguous».| Вид соединения | Какие строки возвращает |
|---|---|
INNER JOIN | только совпавшие пары |
LEFT JOIN | все из левой таблицы + совпавшие из правой |
RIGHT JOIN | все из правой + совпавшие из левой |
FULL OUTER JOIN | все из обеих, несовпавшие дополнены NULL |
CROSS JOIN | все возможные пары, без условия |
-- INNER: слово JOIN без уточнения означает именно его
-- Пользователи и посты, которые они лайкнули
SELECT u.name, p.title, l.liked_at
FROM likes l
JOIN users u ON u.id = l.user_id
JOIN posts p ON p.id = l.post_id
ORDER BY u.name, l.liked_at;
-- LEFT: все пользователи, даже без единого лайка
SELECT u.name, l.post_id
FROM users u
LEFT JOIN likes l ON l.user_id = u.id
ORDER BY u.name;
Главное применение LEFT JOIN — найти строки без пары. Приём называется антисоединением: соединяем и оставляем те, где справа не нашлось ничего.
-- Пользователи, не поставившие ни одного лайка
SELECT u.name
FROM users u
LEFT JOIN likes l ON l.user_id = u.id
WHERE l.id IS NULL;
-- Сообщества без постов
SELECT c.name
FROM communities c
LEFT JOIN posts p ON p.community_id = c.id
WHERE p.id IS NULL;
NULL нужно столбец, который в правой таблице не может быть NULL сам по себе — первичный ключ или столбец с NOT NULL. Если проверить необязательный столбец, в результат попадут и строки, у которых пара нашлась, но значение в ней пустое.Для INNER JOIN перенос условия из ON в WHERE ничего не меняет. Для LEFT JOIN — меняет всё.
-- Условие в ON: все пользователи; лайки подтягиваются только сделанные до 6 января
SELECT u.name, l.liked_at
FROM users u
LEFT JOIN likes l
ON l.user_id = u.id
AND l.liked_at < DATE '2026-01-06';
-- 13 строк: все 10 пользователей, у семи — NULL в liked_at
-- Условие в WHERE: строки с NULL отсеиваются, LEFT превращается в INNER
SELECT u.name, l.liked_at
FROM users u
LEFT JOIN likes l
ON l.user_id = u.id
WHERE l.liked_at < DATE '2026-01-06';
-- 6 строк: только трое пользователей, лайкавших до 6 января
ON работает в момент соединения, WHERE — после него. Несовпавшая строка получает NULL в столбцах правой таблицы, и любое условие в WHERE на эти столбцы даёт UNKNOWN — строка отсеивается. Это самая частая причина того, что «LEFT JOIN почему-то работает как INNER».-- FULL OUTER: строки из обеих таблиц, включая непарные с обеих сторон
SELECT coalesce(l.user_id, v.user_id) AS user_id,
l.post_id, v.community_id
FROM likes l
FULL OUTER JOIN views v ON v.user_id = l.user_id;
-- Self-join: таблица соединяется сама с собой
-- Пары пользователей из одного города
SELECT a.name AS user_a, b.name AS user_b, a.city
FROM users a
JOIN users b ON b.city = a.city
AND b.id > a.id -- чтобы пара не повторялась зеркально
ORDER BY a.city;
b.id > a.id в self-join решает сразу две задачи: исключает соединение строки самой с собой и убирает зеркальные дубликаты — пара «Андрей и Анна» не повторится как «Анна и Андрей». Без него результат будет вчетверо больше нужного.-- NATURAL JOIN: соединяет по всем одноимённым столбцам автоматически
SELECT * FROM posts NATURAL JOIN communities;
-- Соединится по id — не по community_id! Результат бессмысленный.
NATURAL JOIN в рабочем коде применять не следует. Он соединяет по совпадению имён, а имена меняются: добавили в обе таблицы столбец created_at — и запрос молча начал соединять ещё и по нему, вернув другой результат. Ошибки не будет, будут неверные данные. Всегда пишите условие явно.| Функция | Что делает | Учитывает NULL |
|---|---|---|
count(*) | число строк | да, считает все строки |
count(столбец) | число непустых значений | нет, пропускает NULL |
count(DISTINCT столбец) | число различных значений | нет |
sum, avg | сумма, среднее | нет, NULL исключаются |
min, max | минимум, максимум | нет |
string_agg | склейка строк через разделитель | нет |
array_agg | собрать значения в массив | да, NULL попадёт в массив |
SELECT count(*) AS total_users,
count(city) AS with_city, -- меньше, если есть NULL
count(DISTINCT city) AS cities,
round(avg(age), 1) AS avg_age,
min(age), max(age)
FROM users;
avg игнорирует NULL, а не считает их нулями. Если у половины сотрудников премия не назначена, avg(bonus) вернёт среднее по тем, у кого она есть, — а начальник ждал среднее по всем. Разница может быть двукратной. Когда NULL означает «ноль», пишите avg(coalesce(bonus, 0)); когда «неизвестно» — оставляйте как есть, но оговаривайте это в отчёте.-- Число лайков по каждому пользователю
SELECT user_id, count(*) AS likes_count
FROM likes
GROUP BY user_id
ORDER BY likes_count DESC, user_id;
-- Группировка по нескольким столбцам
SELECT u.city, u.gender, count(*) AS cnt, round(avg(u.age), 1) AS avg_age
FROM users u
GROUP BY u.city, u.gender
ORDER BY u.city, u.gender;
-- HAVING фильтрует группы, WHERE — строки до группировки
SELECT c.name, count(*) AS posts_count, round(avg(p.score)) AS avg_score
FROM posts p
JOIN communities c ON c.id = p.community_id
WHERE p.score > 500 -- отбор строк ДО группировки
GROUP BY c.name
HAVING count(*) >= 2 -- отбор групп ПОСЛЕ
ORDER BY avg_score DESC;
SELECT должен быть либо в GROUP BY, либо внутри агрегатной функции.WHERE не может содержать агрегатов, HAVING — может и обычно должен.WHERE всё, что можно: это уменьшает объём данных до группировки и ускоряет запрос.Многоуровневые итоги без нескольких запросов:
-- ROLLUP: итоги по городу и общий итог одной строкой
SELECT coalesce(city, 'ВСЕГО') AS city,
coalesce(gender, 'все') AS gender,
count(*) AS cnt
FROM users
GROUP BY ROLLUP (city, gender)
ORDER BY city NULLS LAST, gender NULLS LAST;
ROLLUP (a, b) даёт группировки по (a, b), по (a) и итог по всей таблице. CUBE (a, b) добавляет ещё и группировку по (b) — все сочетания. Строки итогов отличаются тем, что в столбце группировки стоит NULL; отличить настоящий NULL от итогового помогает функция grouping().Это самая дорогая ошибка в аналитических запросах. Она не вызывает ошибку и не выглядит подозрительно — просто числа в отчёте больше настоящих.
Механика: при соединении «один ко многим» строка левой таблицы дублируется столько раз, сколько нашлось пар справа. Любой агрегат после этого считает дубликаты.
-- НЕВЕРНО: два независимых соединения один-ко-многим
SELECT u.name,
count(l.id) AS likes_count,
count(v.id) AS views_count
FROM users u
LEFT JOIN likes l ON l.user_id = u.id
LEFT JOIN views v ON v.user_id = u.id
GROUP BY u.name;
У пользователя 1 два лайка и три просмотра. После двух соединений получается 2 × 3 = 6 строк, и обе величины окажутся равными шести вместо двух и трёх.
| Что считаем | Правда | Покажет неверный запрос |
|---|---|---|
| лайков | 2 | 6 |
| просмотров | 3 | 6 |
Три способа сделать правильно:
-- Способ 1: агрегировать до соединения, в CTE
WITH l AS (SELECT user_id, count(*) AS cnt FROM likes GROUP BY user_id),
v AS (SELECT user_id, count(*) AS cnt FROM views GROUP BY user_id)
SELECT u.name,
coalesce(l.cnt, 0) AS likes_count,
coalesce(v.cnt, 0) AS views_count
FROM users u
LEFT JOIN l ON l.user_id = u.id
LEFT JOIN v ON v.user_id = u.id
ORDER BY u.name;
-- Способ 2: коррелированные подзапросы — короче, но медленнее на объёме
SELECT u.name,
(SELECT count(*) FROM likes l WHERE l.user_id = u.id) AS likes_count,
(SELECT count(*) FROM views v WHERE v.user_id = u.id) AS views_count
FROM users u;
-- Способ 3: считать различные значения — работает, но маскирует проблему
SELECT u.name,
count(DISTINCT l.id) AS likes_count,
count(DISTINCT v.id) AS views_count
FROM users u
LEFT JOIN likes l ON l.user_id = u.id
LEFT JOIN views v ON v.user_id = u.id
GROUP BY u.name;
count, но не спасает sum: sum(DISTINCT цена) схлопнет одинаковые суммы разных позиций и даст заниженный итог. Для сумм годятся только первые два способа. Появление DISTINCT в агрегате — почти всегда сигнал, что запрос размножает строки и структуру стоит пересмотреть, а не залечивать симптом.count(*) до соединения и после. Если число строк выросло, а вы ожидали соединение «один к одному» — агрегаты уже врут. Второй приём: проверьте один-два значения вручную по исходным таблицам. Отчёт, который никто не сверял с сырыми данными, обычно неверен.Одну и ту же задачу часто можно решить тремя способами. Они не эквивалентны.
-- Задача: сообщества, у которых есть хотя бы один пост
-- JOIN — вернёт сообщество столько раз, сколько у него постов!
SELECT DISTINCT c.name
FROM communities c JOIN posts p ON p.community_id = c.id;
-- IN — по одной строке на сообщество
SELECT c.name FROM communities c
WHERE c.id IN (SELECT community_id FROM posts);
-- EXISTS — то же, но останавливается на первом совпадении
SELECT c.name FROM communities c
WHERE EXISTS (SELECT 1 FROM posts p WHERE p.community_id = c.id);
| Способ | Когда применять | Подвох |
|---|---|---|
JOIN | нужны столбцы из обеих таблиц | размножает строки, требует DISTINCT |
IN | простая проверка вхождения | NOT IN ломается на NULL |
EXISTS | проверка существования | нет; предпочтительный вариант для «есть ли хоть один» |
JOIN; нужен только факт наличия или отсутствия — EXISTS и NOT EXISTS. Планировщик PostgreSQL часто приводит IN и EXISTS к одному плану, так что разница здесь больше в надёжности и читаемости, чем в скорости.LEFT JOIN + WHERE правый_ключ IS NULLWHERE превращает LEFT JOIN в INNER; для внешних соединений оно должно быть в ONNATURAL JOIN не применять: он тихо меняет поведение при добавлении столбцовb.id > a.id убирает зеркальные парыcount(*) считает строки, count(столбец) пропускает NULLavg игнорирует NULL, а не считает их нулямиWHERE фильтрует до группировки, HAVING — послеDISTINCT спасает count, но не sumJOIN; нужен факт наличия — EXISTS