JOIN соединяет строки двух таблиц по условию: INNER JOIN оставляет только пары, нашедшие друг друга, а LEFT JOIN сохраняет все строки левой таблицы и подставляет NULL, если пары нет. Агрегатные функции (count, sum, avg, min, max) сворачивают много строк в одно значение и пропускают NULL, кроме count(*). GROUP BY считает агрегаты отдельно для каждой группы, WHERE фильтрует строки до группировки, а HAVING — готовые группы после неё.
После материала вы сможете
- соединить две и больше таблиц через
INNER JOINиLEFT JOINи найти строки без пары, например клиентов без заказов - посчитать итоги функциями
count,sum,avg,min,maxи объяснить, как они обрабатываютNULL - сгруппировать строки
GROUP BY, отфильтровать группыHAVINGи объяснить, чемHAVINGотличается отWHERE - разбить сложный отчёт на шаги с
WITHи распознать дубли послеJOIN
Что знать заранее
Проверено на
PostgreSQL 17 (17.11, образ postgres:17-alpine)psql 17Docker Engine 29
Сверено с документацией: 11 октября 2026 г.
Содержание
Зачем JOIN: данные лежат в разных таблицах
В уроке «SQL с нуля» мы работали с одной таблицей за раз. Но в учебной базе магазина заказ хранит только номер клиента client_id, а имя клиента лежит в clients. Так задумано: данные о клиенте записаны один раз, а заказы на него ссылаются. Чтобы увидеть заказ вместе с именем, таблицы соединяют запросом с JOIN.
Если контейнер с учебной базой уже остановлен, поднимите его заново тем же скриптом shop.sql:
# shop.sql is the script from the "SQL from scratch" lesson
docker run -d --rm --name shop-db -e POSTGRES_PASSWORD=test postgres:17-alpine
docker exec -i shop-db psql -U postgres < shop.sql
docker exec -it shop-db psql -U postgres -P null=NULLSELECT o.id, o.ordered_on, o.status, c.name
FROM orders AS o
JOIN clients AS c ON c.id = o.client_id
ORDER BY o.id;1
- ordered_on
- 2026-09-02
- status
- shipped
- name
- Aigerim Seitova
2
- ordered_on
- 2026-09-05
- status
- shipped
- name
- Daniyar Akhmetov
3
- ordered_on
- 2026-09-14
- status
- paid
- name
- Aigerim Seitova
4
- ordered_on
- 2026-09-20
- status
- cancelled
- name
- Madina Omarova
5
- ordered_on
- 2026-09-28
- status
- shipped
- name
- Timur Kim
Как это читать: для каждой строки orders база ищет строки clients, для которых условие после ON истинно, и склеивает их в одну строку результата. AS o и AS c — короткие псевдонимы таблиц. Документация PostgreSQL считает хорошим стилем указывать таблицу у каждого столбца в запросе с соединением: если в обеих таблицах окажется столбец с одинаковым именем, например id или name, неквалифицированный запрос сломается.
Соединений в одном запросе может быть несколько. Позиция заказа ссылается и на заказ, и на товар, поэтому для чека нужны все четыре таблицы:
SELECT o.id AS order_id, c.name AS client, p.name AS product,
oi.quantity, oi.price, oi.quantity * oi.price AS line_total
FROM orders AS o
JOIN clients AS c ON c.id = o.client_id
JOIN order_items AS oi ON oi.order_id = o.id
JOIN products AS p ON p.id = oi.product_id
WHERE o.id IN (1, 5)
ORDER BY o.id, p.name;1
- client
- Aigerim Seitova
- product
- Ceramic Mug 350 ml
- quantity
- 2
- price
- 2500.00
- line_total
- 5000.00
1
- client
- Aigerim Seitova
- product
- Espresso Blend 1 kg
- quantity
- 1
- price
- 12900.00
- line_total
- 12900.00
5
- client
- Timur Kim
- product
- Ceramic Mug 350 ml
- quantity
- 1
- price
- 2500.00
- line_total
- 2500.00
5
- client
- Timur Kim
- product
- Ethiopia Yirgacheffe 250 g
- quantity
- 1
- price
- 5600.00
- line_total
- 5600.00
5
- client
- Timur Kim
- product
- Masala Chai 200 g
- quantity
- 2
- price
- 3800.00
- line_total
- 7600.00
INNER JOIN и LEFT JOIN — наглядно
Слово JOIN без уточнения означает INNER JOIN — внутреннее соединение. В результат попадают только пары, нашедшие друг друга. Клиенты, у которых нет ни одного заказа, из такого запроса молча исчезают:
SELECT c.id, c.name, o.id AS order_id, o.status
FROM clients AS c
JOIN orders AS o ON o.client_id = c.id
ORDER BY c.id, o.id;SELECT c.id, c.name, o.id AS order_id, o.status
FROM clients AS c
LEFT JOIN orders AS o ON o.client_id = c.id
ORDER BY c.id, o.id;1
- name
- Aigerim Seitova
- order_id
- 1
- status
- shipped
1
- name
- Aigerim Seitova
- order_id
- 3
- status
- paid
1
- name
- Aigerim Seitova
- order_id
- 9
- status
- paid
2
- name
- Daniyar Akhmetov
- order_id
- 2
- status
- shipped
2
- name
- Daniyar Akhmetov
- order_id
- 10
- status
- new
3
- name
- Madina Omarova
- order_id
- 4
- status
- cancelled
3
- name
- Madina Omarova
- order_id
- 7
- status
- shipped
4
- name
- Timur Kim
- order_id
- 5
- status
- shipped
5
- name
- Aliya Bekova
- order_id
- 6
- status
- paid
6
- name
- Ruslan Ivanov
- order_id
- 8
- status
- new
7
- name
- Zarina Kaliyeva
- order_id
- NULL
- status
- NULL
8
- name
- Sergey Pak
- order_id
- NULL
- status
- NULL
INNER JOIN
- Только строки, у которых нашлась пара по условию
ON - Строки без пары в любой из таблиц отбрасываются
- Отчёт «заказы с именами клиентов»
LEFT JOIN
- Каждая строка левой таблицы хотя бы один раз
- Если пары нет, столбцы правой таблицы —
NULL - Отчёт «все клиенты и их заказы, если есть», поиск строк без пары
Документация описывает LEFT JOIN именно так: сначала выполняется внутреннее соединение, затем для каждой строки левой таблицы без пары добавляется строка с NULL в столбцах правой. Отсюда классический приём — найти строки без пары: оставить только те, где ключ правой таблицы равен NULL.
SELECT c.id, c.name, c.created_on
FROM clients AS c
LEFT JOIN orders AS o ON o.client_id = c.id
WHERE o.id IS NULL
ORDER BY c.id;7
- name
- Zarina Kaliyeva
- created_on
- 2026-09-10
8
- name
- Sergey Pak
- created_on
- 2026-09-30
SELECT p.id, p.name
FROM products AS p
LEFT JOIN order_items AS oi ON oi.product_id = p.id
WHERE oi.product_id IS NULL;9
- name
- Gift Box
Есть ещё RIGHT JOIN (то же, что LEFT, но сохраняет правую таблицу) и FULL JOIN (сохраняет строки обеих). На практике RIGHT JOIN обычно переписывают как LEFT JOIN, поменяв таблицы местами, — так запрос легче читать.
-- Intent: every client plus their paid orders
SELECT c.name, o.id AS order_id, o.status
FROM clients AS c
LEFT JOIN orders AS o ON o.client_id = c.id
WHERE o.status = 'paid'
ORDER BY c.id, o.id;Aigerim Seitova
- order_id
- 3
- status
- paid
Aigerim Seitova
- order_id
- 9
- status
- paid
Aliya Bekova
- order_id
- 6
- status
- paid
SELECT c.name, o.id AS order_id, o.status
FROM clients AS c
LEFT JOIN orders AS o ON o.client_id = c.id AND o.status = 'paid'
ORDER BY c.id, o.id;Aigerim Seitova
- order_id
- 3
- status
- paid
Aigerim Seitova
- order_id
- 9
- status
- paid
Daniyar Akhmetov
- order_id
- NULL
- status
- NULL
Madina Omarova
- order_id
- NULL
- status
- NULL
Timur Kim
- order_id
- NULL
- status
- NULL
Aliya Bekova
- order_id
- 6
- status
- paid
Ruslan Ivanov
- order_id
- NULL
- status
- NULL
Zarina Kaliyeva
- order_id
- NULL
- status
- NULL
Sergey Pak
- order_id
- NULL
- status
- NULL
Агрегаты: COUNT, SUM, AVG, MIN, MAX и NULL
Агрегатная функция вычисляет одно значение по многим строкам. Без GROUP BY весь результат — одна группа, и запрос возвращает ровно одну строку.
SELECT count(*) AS products,
count(weight_g) AS with_weight,
min(price) AS cheapest,
max(price) AS most_expensive,
round(avg(price), 2) AS avg_price,
avg(weight_g) AS avg_weight
FROM products;9
- with_weight
- 5
- cheapest
- 2500.00
- most_expensive
- 18500.00
- avg_price
- 8366.67
- avg_weight
- 360.0000000000000000
Здесь видно главное правило: агрегаты пропускают NULL. count(*) считает строки, а count(weight_g) — только строки, где вес задан. avg(weight_g) — среднее по пяти товарам с известным весом: (1000 + 250 + 250 + 100 + 200) / 5 = 360, а не сумма, делённая на 9. Это почти всегда то, что нужно, но об этом важно помнить, когда NULL означает «ноль», а не «неизвестно».
count(*)
- Что считает
- Все строки группы
- Результат на orders
- 10
count(promo_code)
- Что считает
- Строки, где значение не
NULL - Результат на orders
- 3
count(DISTINCT promo_code)
- Что считает
- Разные значения, кроме
NULL - Результат на orders
- 2
SELECT count(*) AS orders, count(promo_code) AS with_promo,
count(DISTINCT promo_code) AS distinct_promos
FROM orders;
-- orders | with_promo | distinct_promos
-- --------+------------+-----------------
-- 10 | 3 | 2
-- (1 row)Вторая ловушка: по документации все агрегаты, кроме count, возвращают NULL, если строк нет вовсе. Сумма пустого набора — не ноль. У заказа 10 позиций пока нет:
SELECT sum(quantity * price) AS revenue
FROM order_items
WHERE order_id = 10;
SELECT coalesce(sum(quantity * price), 0) AS revenue
FROM order_items
WHERE order_id = 10;
SELECT count(*) AS lines
FROM order_items
WHERE order_id = 10;
-- revenue
-- ---------
-- NULL
-- (1 row)
--
-- revenue
-- ---------
-- 0
-- (1 row)
--
-- lines
-- -------
-- 0
-- (1 row)Ещё один полезный инструмент — FILTER: он ограничивает строки для одного конкретного агрегата, не трогая остальные. Так одним запросом считают несколько показателей:
SELECT count(*) AS orders,
count(*) FILTER (WHERE status = 'cancelled') AS cancelled,
count(*) FILTER (WHERE promo_code IS NOT NULL) AS with_promo
FROM orders;10
- cancelled
- 1
- with_promo
- 3
GROUP BY и HAVING
GROUP BY делит строки на группы с одинаковым значением и считает агрегаты для каждой группы отдельно. Выручка по категориям — это соединение позиций с заказами и товарами, фильтр по статусу и группировка по категории. Выручкой в уроке считаем оплаченные и отгруженные заказы: новые ещё не оплачены, отменённые не состоялись.
SELECT p.category,
sum(oi.quantity) AS units,
sum(oi.quantity * oi.price) AS revenue
FROM order_items AS oi
JOIN orders AS o ON o.id = oi.order_id
JOIN products AS p ON p.id = oi.product_id
WHERE o.status IN ('paid', 'shipped')
GROUP BY p.category
ORDER BY revenue DESC;coffee
- units
- 9
- revenue
- 77500.00
equipment
- units
- 2
- revenue
- 27400.00
tea
- units
- 3
- revenue
- 10800.00
tableware
- units
- 3
- revenue
- 7500.00
Правило группировки: в SELECT можно выводить только столбцы из GROUP BY и агрегаты. Если вывести столбец, у которого внутри группы много значений, база не знает, какое выбрать, и отказывается выполнять запрос:
SELECT p.category, p.name, sum(oi.quantity) AS units
FROM order_items AS oi
JOIN products AS p ON p.id = oi.product_id
GROUP BY p.category;
-- ERROR: column "p.name" must appear in the GROUP BY clause or be used in an aggregate function
-- LINE 1: SELECT p.category, p.name, sum(oi.quantity) AS units
-- ^Исключение одно: если сгруппировать по первичному ключу, остальные столбцы этой таблицы можно выводить — они однозначно определяются ключом. Поэтому GROUP BY c.id достаточно, чтобы вывести c.name. В примерах мы пишем GROUP BY c.id, c.name — так запрос понятнее и переносим на другие СУБД.
HAVING фильтрует уже готовые группы. Найдём клиентов с двумя и более заказами, не считая отменённых:
SELECT c.name, count(*) AS orders
FROM clients AS c
JOIN orders AS o ON o.client_id = c.id
WHERE o.status <> 'cancelled'
GROUP BY c.id, c.name
HAVING count(*) >= 2
ORDER BY orders DESC, c.name;Aigerim Seitova
- orders
- 3
Daniyar Akhmetov
- orders
- 2
Разница между WHERE и HAVING следует из порядка выполнения. WHERE решает, какие строки войдут в группы, поэтому агрегата в нём ещё не существует:
SELECT client_id, count(*)
FROM orders
WHERE count(*) >= 2
GROUP BY client_id;
-- ERROR: aggregate functions are not allowed in WHERE
-- LINE 3: WHERE count(*) >= 2
-- ^Документация советует ставить в WHERE всё, что не требует агрегата: так база не тратит работу на группировку строк, которые потом всё равно отбросит. В HAVING остаются только условия на агрегаты.
При группировке все NULL попадают в одну группу. Клиентка без города образует отдельную строку:
SELECT city, count(*) AS clients
FROM clients
GROUP BY city
ORDER BY clients DESC, city;Almaty
- clients
- 3
Astana
- clients
- 2
Karaganda
- clients
- 1
Shymkent
- clients
- 1
NULL
- clients
- 1
Подзапросы и CTE (WITH)
Подзапрос — SELECT внутри другого запроса. Агрегат нельзя написать в WHERE, но можно сравнить со значением, которое посчитал отдельный подзапрос. Этот приём показан и в учебнике PostgreSQL:
SELECT name, price
FROM products
WHERE price > (SELECT avg(price) FROM products)
ORDER BY price DESC;Hand Grinder
- price
- 18500.00
Gift Box
- price
- 15000.00
Espresso Blend 1 kg
- price
- 12900.00
French Press 600 ml
- price
- 8900.00
Группировать можно и по выражению. Функция date_trunc('month', …) обрезает дату до первого числа месяца, а ::date приводит результат к типу date. В GROUP BY здесь использовано имя выходного столбца month — PostgreSQL это разрешает.
SELECT date_trunc('month', o.ordered_on)::date AS month,
count(DISTINCT o.id) AS orders,
sum(oi.quantity * oi.price) AS revenue
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.id
WHERE o.status IN ('paid', 'shipped')
GROUP BY month
ORDER BY month;2026-09-01
- orders
- 4
- revenue
- 55500.00
2026-10-01
- orders
- 3
- revenue
- 67700.00
Когда отчёт строится в несколько шагов, удобно WITH. Документация описывает CTE (common table expression) как вспомогательный запрос, который ведёт себя как временная таблица на время одного запроса. Сначала посчитаем сумму каждого заказа, затем по этим суммам — выручку и средний чек по клиентам:
WITH order_totals AS (
SELECT o.id, o.client_id, sum(oi.quantity * oi.price) AS total
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.id
WHERE o.status IN ('paid', 'shipped')
GROUP BY o.id, o.client_id
)
SELECT c.name,
count(*) AS orders,
sum(t.total) AS revenue,
round(avg(t.total)) AS avg_check
FROM order_totals AS t
JOIN clients AS c ON c.id = t.client_id
GROUP BY c.id, c.name
ORDER BY revenue DESC;Aigerim Seitova
- orders
- 3
- revenue
- 58200.00
- avg_check
- 19400
Aliya Bekova
- orders
- 1
- revenue
- 25800.00
- avg_check
- 25800
Timur Kim
- orders
- 1
- revenue
- 15700.00
- avg_check
- 15700
Daniyar Akhmetov
- orders
- 1
- revenue
- 13000.00
- avg_check
- 13000
Madina Omarova
- orders
- 1
- revenue
- 10500.00
- avg_check
- 10500
SELECT name FROM products
WHERE id NOT IN (SELECT product_id FROM order_items);
SELECT name FROM products
WHERE id NOT IN (1, 2, 3, 4, 5, 6, 7, 8, NULL);
SELECT p.name FROM products AS p
WHERE NOT EXISTS (
SELECT 1 FROM order_items AS oi WHERE oi.product_id = p.id
);
-- name
-- ----------
-- Gift Box
-- (1 row)
--
-- name
-- ------
-- (0 rows)
--
-- name
-- ----------
-- Gift Box
-- (1 row)Типичные ошибки: дубли после JOIN и COUNT(*) против COUNT(столбец)
Соединение «один ко многим» размножает строки: каждый заказ повторяется столько раз, сколько в нём позиций. Если после этого посчитать count(*), получится число позиций, а не заказов:
-- Wrong: counts order lines, not orders
SELECT c.name, count(*) AS orders
FROM clients AS c
JOIN orders AS o ON o.client_id = c.id
JOIN order_items AS oi ON oi.order_id = o.id
GROUP BY c.id, c.name
ORDER BY c.id;Aigerim Seitova
- orders
- 5
Daniyar Akhmetov
- orders
- 2
Madina Omarova
- orders
- 3
Timur Kim
- orders
- 3
Aliya Bekova
- orders
- 1
Ruslan Ivanov
- orders
- 1
SELECT c.name,
count(DISTINCT o.id) AS orders,
sum(oi.quantity * oi.price) AS total
FROM clients AS c
JOIN orders AS o ON o.client_id = c.id
JOIN order_items AS oi ON oi.order_id = o.id
GROUP BY c.id, c.name
ORDER BY c.id;Aigerim Seitova
- orders
- 3
- total
- 58200.00
Daniyar Akhmetov
- orders
- 1
- total
- 13000.00
Madina Omarova
- orders
- 2
- total
- 29000.00
Timur Kim
- orders
- 1
- total
- 15700.00
Aliya Bekova
- orders
- 1
- total
- 25800.00
Ruslan Ivanov
- orders
- 1
- total
- 9600.00
Обратите внимание на вторую деталь в результате: внутреннее соединение с позициями отбросило пустой заказ 10. Если пустые заказы важны для отчёта, нужен LEFT JOIN и coalesce для суммы.
Вторая частая ошибка — count(*) после LEFT JOIN. Для клиента без заказов в результате всё равно есть одна строка (с NULL в столбцах заказа), и count(*) посчитает её. Считать нужно столбец правой таблицы:
SELECT c.name, count(o.id) AS orders, count(*) AS rows_in_group
FROM clients AS c
LEFT JOIN orders AS o ON o.client_id = c.id
GROUP BY c.id, c.name
ORDER BY c.id;Aigerim Seitova
- orders
- 3
- rows_in_group
- 3
Daniyar Akhmetov
- orders
- 2
- rows_in_group
- 2
Madina Omarova
- orders
- 2
- rows_in_group
- 2
Timur Kim
- orders
- 1
- rows_in_group
- 1
Aliya Bekova
- orders
- 1
- rows_in_group
- 1
Ruslan Ivanov
- orders
- 1
- rows_in_group
- 1
Zarina Kaliyeva
- orders
- 0
- rows_in_group
- 1
Sergey Pak
- orders
- 0
- rows_in_group
- 1
Сумма или количество больше ожидаемого
- Причина
JOIN«один ко многим» размножил строки- Исправление
count(DISTINCT …)или сначала агрегировать вWITH, потом соединять
Клиенты без заказов пропали из отчёта
- Причина
INNER JOINвместоLEFT JOIN- Исправление
LEFT JOINот таблицы, строки которой нужны все
LEFT JOIN ведёт себя как INNER
- Причина
- Условие на правую таблицу стоит в
WHERE - Исправление
- Перенести условие в
ON
У клиента без заказов 1 заказ
- Причина
count(*)послеLEFT JOIN- Исправление
count(столбец правой таблицы)
Итог пустой, а не 0
- Причина
sumпо пустому набору —NULL- Исправление
coalesce(sum(…), 0)
must appear in the GROUP BY clause
- Причина
- В
SELECTстолбец вне группы - Исправление
- Добавить в
GROUP BYили обернуть в агрегат
Упражнения: выручка по товарам и клиенты без заказов
Упражнения выполняются на той же учебной базе. Выручка — сумма quantity * price по заказам в статусах paid и shipped. Ответы проверены на PostgreSQL 17.11; ожидаемый вывод — в комментариях к решению.
Дальше по траектории — запуск PostgreSQL для своего проекта и проектирование схемы: как выбрать таблицы и связи, чтобы такие отчёты оставались простыми.
Как проверено4 · пройденосверено 11 октября 2026 г.
- запущено, работает
shop.sql (seed из урока sql-select) и команды перезапуска
тот же файл, что в sql-select; загружен в docker postgres:17-alpine (17.11), контейнер kb-pg-db0103; команды запуска проверены в sql-select (контейнер kb-pg-db0103-setup)
- запущено, работает
Запросы урока (24 шт.: INNER/LEFT JOIN, ON vs WHERE, агрегаты и NULL, FILTER, GROUP BY/HAVING, ошибки группировки и агрегата в WHERE, подзапрос, date_trunc, CTE, NOT IN vs NOT EXISTS, дубли после JOIN)
каждый запрос — отдельный .sql, `docker exec -i kb-pg-db0103 psql -U postgres -X -P null=NULL < file` на свежезагруженной базе; таблицы и комментарии с выводом собраны в статью скриптом из файлов вывода; сокращённые таблицы помечены
- запущено, работает
GROUP BY по первичному ключу (c.id) с выводом c.name
отдельный запрос x_pk_group.sql выполнен без ошибки — утверждение в тексте подтверждено
- запущено, работает
Решения 6 упражнений + неправильный вариант упр. 2 (условие в WHERE)
выполнены на учебной базе; неправильный вариант вернул 0 строк, как сказано в подсказке; сумма упр. 1 (55 500) совпала с отчётом по месяцам
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.