Раздел 10 — SQL: JOIN, группировка, подзапросы

Соединение таблиц и получение сводных показателей — ядро аналитических запросов

Прогресс курса Раздел 10 из 13

Что вы освоите в этом разделе

8 академических часов: 2 часа теории, 6 часов практики. Самый объёмный раздел блока SQL и самый востребованный на практике.
01

Соединение: зачем и как

Нормализация разложила данные по таблицам (раздел 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все возможные пары, без условия
02

INNER JOIN и LEFT 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. Если проверить необязательный столбец, в результат попадут и строки, у которых пара нашлась, но значение в ней пустое.

ON или WHERE — для внешних соединений это разные вещи

Для 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 января
Объяснение через порядок выполнения из раздела 9: ON работает в момент соединения, WHERE — после него. Несовпавшая строка получает NULL в столбцах правой таблицы, и любое условие в WHERE на эти столбцы даёт UNKNOWN — строка отсеивается. Это самая частая причина того, что «LEFT JOIN почему-то работает как INNER».
03

FULL OUTER, self-join и NATURAL

-- 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 — и запрос молча начал соединять ещё и по нему, вернув другой результат. Ошибки не будет, будут неверные данные. Всегда пишите условие явно.
04

Агрегатные функции

ФункцияЧто делаетУчитывает 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)); когда «неизвестно» — оставляйте как есть, но оговаривайте это в отчёте.
05

GROUP BY и HAVING

-- Число лайков по каждому пользователю
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;

Правило GROUP BY

  • Каждый столбец в SELECT должен быть либо в GROUP BY, либо внутри агрегатной функции.
  • Исключение: если группировка идёт по первичному ключу таблицы, PostgreSQL разрешает выводить остальные её столбцы — они всё равно определены однозначно.
  • 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().
06

Ловушка: соединение размножает строки

Это самая дорогая ошибка в аналитических запросах. Она не вызывает ошибку и не выглядит подозрительно — просто числа в отчёте больше настоящих.

Механика: при соединении «один ко многим» строка левой таблицы дублируется столько раз, сколько нашлось пар справа. Любой агрегат после этого считает дубликаты.

-- НЕВЕРНО: два независимых соединения один-ко-многим
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 строк, и обе величины окажутся равными шести вместо двух и трёх.

Что считаемПравдаПокажет неверный запрос
лайков26
просмотров36
В учебной базе лайк поста и просмотр ленты сообщества — независимые события (см. раздел 2). Именно поэтому соединять их напрямую нельзя: между ними нет связи, по которой строки соотносились бы один к одному. Тот же эффект возникает в любой схеме, где от одной сущности расходятся две связи «ко многим»: заказ с позициями и оплатами, договор с приложениями и платежами.

Три способа сделать правильно:

-- Способ 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(*) до соединения и после. Если число строк выросло, а вы ожидали соединение «один к одному» — агрегаты уже врут. Второй приём: проверьте один-два значения вручную по исходным таблицам. Отчёт, который никто не сверял с сырыми данными, обычно неверен.
07

JOIN, IN или EXISTS

Одну и ту же задачу часто можно решить тремя способами. Они не эквивалентны.

-- Задача: сообщества, у которых есть хотя бы один пост

-- 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 NULL
  • Условие в WHERE превращает LEFT JOIN в INNER; для внешних соединений оно должно быть в ON
  • NATURAL JOIN не применять: он тихо меняет поведение при добавлении столбцов
  • В self-join условие b.id > a.id убирает зеркальные пары
  • count(*) считает строки, count(столбец) пропускает NULL
  • avg игнорирует NULL, а не считает их нулями
  • WHERE фильтрует до группировки, HAVING — после
  • Два соединения «ко многим» перемножают строки и ломают все агрегаты
  • Лечение — агрегировать до соединения; DISTINCT спасает count, но не sum
  • Нужны данные — JOIN; нужен факт наличия — EXISTS
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Раздел 9: Выборка Раздел 11: Индексы и транзакции