DISTINCT ON
Частый запрос в Brew: «покажи по последнему заказу каждого клиента». Не дату последнего заказа — а сам заказ целиком: его номер, сумму, статус. Через GROUP BY это неудобно: max(created_at) отдаёт только дату, а чтобы вытащить остальные колонки именно той строки, приходится делать второй заход (джойнить результат обратно на orders по клиенту и дате). Громоздко и легко ошибиться на одинаковых датах.
Postgres даёт короткий путь — DISTINCT ON. Это не стандарт SQL (фишка именно Postgres), но он решает «по одной строке на группу, причём целую строку» одним выражением.
Как работает DISTINCT ON
SELECT DISTINCT ON (выражение) ... оставляет первую строку для каждого уникального значения выражения. А что считать «первой» — задаёт ORDER BY. Отсюда железное правило: ORDER BY обязан начинаться с того же выражения, что и DISTINCT ON, а дальше идёт критерий «какую строку группы выбрать».
«Последний заказ на клиента» читается так:
SELECT DISTINCT ON (o.customer_id) ...
FROM orders o
ORDER BY o.customer_id, o.created_at DESC, o.id DESC;DISTINCT ON (o.customer_id) — по одной строке на клиента. ORDER BY o.customer_id (обязательное начало) группирует строки клиента вместе, а created_at DESC ставит самый свежий заказ первым в группе — его DISTINCT ON и оставит. id DESC — tie-break на случай одинаковых created_at (две строки с равной датой — иначе «первая» не определена).
Сначала ORDER BY раскладывает строки, потом DISTINCT ON идёт сверху вниз и берёт первую в каждой группе, остальные в группе пропускает:
orders, отсортированные по ORDER BY (o.customer_id, o.created_at DESC, o.id DESC):
customer_id created_at id DISTINCT ON (o.customer_id)
────────────────────────────── ───────────────────────────
1 · Алиса 2025-01-16 #3 ◀── первая для клиента 1 → берём
1 · Алиса 2025-01-15 #1 клиент 1 уже взят → пропуск
2 · Борис 2025-01-15 #2 ◀── первая для клиента 2 → берёмСменив хвост ORDER BY на amount DESC, той же конструкцией получаем самый дорогой заказ на клиента. Критерий выбора живёт в ORDER BY — это и делает DISTINCT ON гибким.
DISTINCT ON против альтернатив
Ту же задачу решают и другие инструменты, и полезно понимать, когда что:
GROUP BY+max()даёт агрегат (дату/сумму), но не целую строку. Чтобы вернуть весь заказ, нужен повторный join — лишний и хрупкий.- Оконные функции (
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC)и фильтр= 1) — стандартный, портируемый способ; он же умеет «top-3 на клиента», а не только top-1. Это юнит 08-02. DISTINCT ON— самый короткий, когда нужна ровно одна строка на группу и проект уже на Postgres.
| приём | что возвращает | строк на группу | портируемость |
|---|---|---|---|
GROUP BY + max() | агрегат (дату/сумму), не строку — за остальными колонками нужен повторный join | — | стандарт SQL |
DISTINCT ON (k) | целую строку, по одной на каждое k | ровно одна | только Postgres |
ROW_NUMBER() … = 1 | целую строку; условие <= N даёт топ-N | одна или N | стандарт SQL (08-02) |
DISTINCT ON ≠ DISTINCT: обычный DISTINCT убирает полные дубликаты строк, DISTINCT ON (col) — оставляет по одной строке на значение col.
Что показывает наш код
Два запроса в query.sql — одинаковая конструкция, разный хвост ORDER BY:
-- name: LatestOrderPerCustomer :many
SELECT DISTINCT ON (o.customer_id) c.name, o.id, o.amount::numeric(10,2)::text, o.status, o.created_at::date::text
FROM orders o JOIN customers c ON c.id::text = o.customer_id
ORDER BY o.customer_id, o.created_at DESC, o.id DESC;
-- PriciestOrderPerCustomer: ... ORDER BY o.customer_id, o.amount DESC, o.id DESC;amount (NUMERIC) приводим к numeric(10,2)::text для стабильного «Ц.КК», дату — к ::date::text. Обе колонки приходят в Go как string — без возни с pgtype.Numeric/pgtype.Date.
Запуск
Подними песочницу (из корня репозитория) и накати схему Brew:
docker compose up -d
make lecture L=04-querying-across-tables/04-04-distinct-on T=db-reset
make lecture L=04-querying-across-tables/04-04-distinct-on(T=run — значение по умолчанию. Изнутри каталога юнита это make db-reset, make run.)
Вывод:
1) Последний заказ на клиента — DISTINCT ON (customer_id), свежесть по created_at:
клиент заказ сумма статус дата
Алиса Иванова #3 9.60 shipped 2025-01-16
Борис Петров #2 3.00 created 2025-01-15
→ у Алисы два заказа (#1 и #3), DISTINCT ON оставил один свежий — #3.
Карины нет: у неё заказов нет, а выбираем мы из orders.
2) Самый дорогой заказ на клиента — тот же DISTINCT ON, но хвост ORDER BY = amount DESC:
клиент заказ сумма
Алиса Иванова #1 10.50
Борис Петров #2 3.00
→ у Алисы теперь #1 (10.50 > 9.60): сменили критерий — сменился победитель группы.У Алисы два заказа; первый запрос оставил самый свежий (#3), второй — самый дорогой (#1). Сменился только хвост ORDER BY — сменился и «победитель» группы. Карина в выборку не попала: мы выбираем из orders, а у неё заказов нет (это не LEFT JOIN).
Заборчик
Что мы упростили.
DISTINCT ONзависит отORDER BYцеликом. Без явного tie-break (id DESC) при двух заказах с одинаковымcreated_at«первая» строка не определена — результат «плавал» бы между прогонами. В проде это источник нестабильных отчётов, поэтому tie-break по уникальной колонке обязателен.DISTINCT ONнестандартен: при переезде на другую СУБД его придётся переписать на оконные функции — в переносимом коде иногда сразу берутROW_NUMBER()(08-02).- На больших таблицах эффективность
DISTINCT ONупирается в индекс подORDER BY— без него сервер отсортирует весь набор (вопрос планов — модуль 06). - Если нужны не «последняя», а «последние N» строк на группу,
DISTINCT ONуже не подходит — это снова оконные функции.
Что забрать с собой
DISTINCT ON (выражение)оставляет одну строку на каждое значениевыражения— и отдаёт строку ЦЕЛИКОМ, не только агрегат.ORDER BYобязан начинаться с выраженияDISTINCT ON; дальше — критерий «какую строку группы оставить».- Меняя хвост
ORDER BY, выбираешь другую строку группы (последний заказ ↔ самый дорогой) — конструкция гибкая. DISTINCT ON≠DISTINCT: первый — по одной строке на ключ, второй — убирает полные дубликаты.- Нужна портируемость или «топ-N на группу» → оконные функции (08-02);
DISTINCT ON— про «ровно одну на группу» в Postgres.
DISTINCT ON и агрегаты отвечали «какая строка» и «сколько». Но часто вопрос про наличие связи: «какие напитки ни разу не заказывали?», «кто из клиентов уже что-то купил?». Это подзапросы — и там снова ждёт та самая ловушка NOT IN со списком, где затесался NULL (тизер из 01-02, разобранный в 03-06): она молча отдаёт пусто. Дальше — 04-05 «Подзапросы: EXISTS vs IN», где эта ловушка станет главным доводом за EXISTS.