DISTINCT в SQL: уникальные строки, COUNT(DISTINCT) и DISTINCT ON
Как работает DISTINCT в SQL на реальной учебной базе: уникальность по всей строке, DISTINCT по нескольким столбцам, COUNT(DISTINCT) для DAU, NULL, отличие от GROUP BY, DISTINCT ON в PostgreSQL и DuckDB, и почему DISTINCT после JOIN прячет ошибку в выручке.
Содержание статьи
Менеджер просит простую цифру: сколько людей пользовались продуктом на неделе с 3 по 9 августа. Первый вариант ответа — count(*) по таблице событий, 3 723. Второй — сложить DAU за семь дней, 3 190. Правильный ответ — 1 851, и получается он одним словом в запросе: count(distinct user_id). Ниже разобрано, как DISTINCT работает на самом деле: по каким колонкам он сравнивает строки, что делает с NULL, чем отличается от GROUP BY и DISTINCT ON, и почему DISTINCT, дописанный после неудачного JOIN, чаще портит отчёт, чем чинит. Все запросы выполняются на учебной базе SQL-курса: 4 613 пользователей, 35 341 событие, 1 251 платёж.
Коротко
SELECT DISTINCT убирает из результата повторяющиеся строки. Повтором считается строка, которая совпала с другой по всем выведенным колонкам сразу.
DISTINCTотносится ко всему списку SELECT, а не к первой колонке:select distinct channel, deviceвернёт уникальные пары.count(distinct user_id)считает людей,count(*)— строки. Для DAU, WAU и MAU нужен первый вариант.- NULL для
DISTINCTравен NULL: пустые значения схлопнутся в одну строку. Аcount(distinct …)пропустит NULL совсем. DISTINCTиGROUP BYбез агрегатов дают одно и то же. Если рядом нуженcountилиsum, пишитеGROUP BY.DISTINCT ON (user_id)в PostgreSQL и DuckDB оставляет одну строку на пользователя — первую поORDER BY. Без сортировки какая строка останется, не определено.- Если
DISTINCTпонадобился, чтобы убрать дубли после JOIN, сначала найдите, откуда дубли взялись.
Что делает SELECT DISTINCT
В таблице users 4 613 строк, по одной на пользователя. У каждого есть канал привлечения. Запрос select distinct channel from users вернёт четыре строки: organic, paid_search, partner, referral. База прочитала все 4 613 значений и оставила по одному экземпляру каждого.
Порядок строк в результате при этом ничем не гарантирован. Если он нужен, добавьте order by. В PostgreSQL у этого сочетания есть ограничение: сортировать можно только по колонкам, которые есть в списке SELECT. Запрос select distinct channel from users order by signup_date там упадёт с ошибкой, и это логично: у одного канала сотни дат регистрации, и непонятно, по какой из них ставить строку.
Частое заблуждение — что скобки делают DISTINCT функцией от одной колонки. Запись select distinct(channel), device означает ровно то же, что select distinct channel, device: скобки просто обернули выражение, а уникальность всё равно считается по паре.
select distinct channel
from users
order by channel;
-- organic, paid_search, partner, referralDISTINCT по нескольким столбцам
Добавьте вторую колонку — и ключ уникальности станет составным. select distinct channel, device возвращает 8 строк: четыре канала на два типа устройства. Каждая комбинация встретилась хотя бы у одного пользователя, поэтому их ровно 4 × 2.
Отсюда рабочее правило: каждая колонка, которую вы добавляете в SELECT, дробит результат. Вывели user_id рядом с каналом — получили 4 613 строк, и DISTINCT больше ничего не убирает. Если после DISTINCT строк почти столько же, сколько в исходной таблице, в список попала колонка с высокой уникальностью.
select distinct channel, device
from users
order by channel, device;
-- 8 строк: organic/desktop, organic/mobile, paid_search/desktop, …Как посчитать уникальных пользователей: COUNT(DISTINCT)
Вернёмся к вопросу из начала. Таблица events хранит одну строку на событие: пользователь может за день открыть приложение несколько раз, создать отчёт, отправить приглашение. count(*) считает эти строки, а менеджер спрашивал про людей.
За неделю с 3 по 9 августа в базе 3 723 события. Уникальных пользователей среди них 1 851 — это и есть WAU. Разница в два раза целиком состоит из повторных действий тех же людей.
Вторая ошибка тоньше. DAU по дням честно посчитан через count(distinct user_id), но потом семь чисел сложили и получили 3 190. Так делать нельзя: человек, который заходил в понедельник и в среду, попал в сумму дважды. Уникальные значения не складываются между периодами, их пересчитывают заново на нужном окне. Подробнее о том, как выбрать определение активности, — в статье про DAU.
| День | Событий, count(*) | DAU, count(distinct user_id) |
|---|---|---|
| 3 августа | 520 | 443 |
| 4 августа | 496 | 419 |
| 5 августа | 587 | 485 |
| 6 августа | 594 | 499 |
| 7 августа | 533 | 446 |
| 8 августа | 506 | 457 |
| 9 августа | 487 | 441 |
| Сумма за неделю | 3 723 | 3 190 — не WAU |
| Вся неделя одним запросом | 3 723 | 1 851 — это WAU |
Неделя 3–9 августа. Строки событий, сумма дневных DAU и уникальные пользователи за всю неделю. Правильный ответ — только последний столбец.
select
cast(event_time as date) as day,
count(*) as events,
count(distinct user_id) as dau
from events
where event_time >= timestamp '2026-08-03'
and event_time < timestamp '2026-08-10'
group by 1
order by 1;NULL в DISTINCT: одна строка вместо ошибки
У 55 пользователей из 4 613 не заполнена страна. select distinct country from users возвращает пять строк: AM, BY, KZ, RU и одну строку с NULL. Все 55 пустых значений схлопнулись в одно, хотя в WHERE условие country = country для них не выполнилось бы. Для DISTINCT, GROUP BY и UNION два NULL считаются одинаковыми.
count(distinct country) на тех же данных возвращает 4. Агрегатные функции пропускают NULL, поэтому пустая страна не считается отдельным значением. В итоге «сколько у нас стран» зависит от формы запроса: 5 через подзапрос с DISTINCT и 4 через count(distinct …). Если пустое значение должно считаться категорией, превратите его в явную метку через coalesce(country, 'unknown').
select count(*) from (select distinct country from users) t; -- 5, NULL посчитан
select count(distinct country) from users; -- 4, NULL пропущенЧем DISTINCT отличается от GROUP BY
Для списка уникальных значений разницы нет. select distinct channel from users и select channel from users group by channel возвращают те же четыре строки, и оптимизатор обычно строит для них одинаковый план.
Разница появляется, как только к уникальным значениям нужны числа. DISTINCT умеет только убирать повторы. GROUP BY собирает группы и позволяет посчитать по каждой что угодно: число пользователей, сумму, долю. Поэтому, если вы пишете DISTINCT, а потом отдельным запросом считаете размер каждой группы, это один GROUP BY.
Второе отличие — в читаемости намерения. GROUP BY channel говорит: «строка результата — это канал». DISTINCT говорит: «я получил повторы и хочу от них избавиться». Первое описывает грейн результата, второе часто скрывает вопрос, почему повторы вообще появились.
select channel, count(*) as users
from users
group by channel
order by channel;
-- organic 1 747, paid_search 1 015, partner 814, referral 1 037DISTINCT ON в PostgreSQL: первая строка в каждой группе
Обычный DISTINCT не помогает, когда нужна не уникальная комбинация, а одна целая строка на пользователя. Например, последний платёж: дата, тариф, тип — первый это платёж или продление. max(paid_at) даст только дату, а DISTINCT по всем колонкам оставит все 1 251 платёж, потому что они различаются.
Для этого в PostgreSQL есть DISTINCT ON (выражения). Он делит строки на группы по выражениям в скобках и из каждой группы оставляет первую строку в порядке ORDER BY. DuckDB поддерживает тот же синтаксис, поэтому запрос ниже выполняется в песочнице курса. Результат — 912 строк, по одной на каждого плательщика.
В PostgreSQL ORDER BY обязан начинаться с тех же выражений, что стоят в DISTINCT ON: сначала user_id, затем правило выбора внутри группы. Иначе запрос падает с ошибкой. DuckDB такой запрос выполняет, но на ту же строгость в PostgreSQL рассчитывать нельзя. Второй ключ сортировки, payment_id desc, нужен как страховка от ничьих: если у пользователя окажется два платежа в один день, выбор станет детерминированным.
Без ORDER BY запрос тоже выполнится — и вернёт что попало. На учебной базе DuckDB без сортировки отдал 912 строк, но у 290 пользователей с несколькими платежами это оказался самый первый платёж, а не последний. Так легли данные при загрузке, и никакой гарантии за этим нет.
select distinct on (user_id)
user_id, paid_at, plan, payment_type, amount
from payments
order by user_id, paid_at desc, payment_id desc;
-- 912 строк; например, user_id 6: 2026-07-17, team, renewal, 39DISTINCT ON или ROW_NUMBER
Та же задача решается окном row_number(). На учебной базе оба запроса дают одинаковый итог: среди последних платежей 349 — первые на тарифе basic, 165 — продления basic, дальше pro и team. Выбор между ними — вопрос переносимости и гибкости.
DISTINCT ON короче и читается как «одна строка на пользователя». Но это расширение PostgreSQL: в MySQL, SQL Server и многих хранилищах его нет. row_number() есть почти везде и умеет больше: два последних платежа вместо одного, фильтр по номеру строки, соседние окна lag и lead в том же запросе.
| Конструкция | Что возвращает | Когда брать | Ограничения |
|---|---|---|---|
| DISTINCT | уникальные комбинации выведенных колонок | список значений, справочник | не умеет считать, прячет причину повторов |
| GROUP BY | строку на группу плюс агрегаты | уникальные значения с числами рядом | все колонки SELECT — в группировке или в агрегате |
| DISTINCT ON | первую строку группы целиком | последняя запись на пользователя в PostgreSQL или DuckDB | только одна строка на группу, нет в MySQL и SQL Server |
| ROW_NUMBER | номер строки внутри группы | top-N на группу, переносимый код | длиннее, нужен подзапрос или CTE |
select user_id, paid_at, plan, payment_type, amount
from (
select *,
row_number() over (
partition by user_id
order by paid_at desc, payment_id desc
) as rn
from payments
) t
where rn = 1;Почему DISTINCT маскирует ошибку в JOIN
Задача: выручка от пользователей, которые открывали приложение. Кажется естественным соединить payments с событиями app_open по user_id и сложить amount. Получается 9 667 строк и сумма 235 833 — в семь с лишним раз больше всей выручки базы. Каждый платёж размножился на число открытий приложения его владельцем.
Дальше рука тянется к DISTINCT. Выводим select distinct p.user_id, p.amount — строк стало 912, сумма 22 328. Выглядит правдоподобно, но это тоже неправда. Настоящая выручка этих пользователей — 30 639. DISTINCT склеил не только размноженные копии, но и настоящие продления: у пользователя на тарифе basic и первый платёж, и каждое продление стоят 19, строки (user_id, 19) одинаковы. Исчезли все 339 продлений на 8 311.
Если добавить в список paid_at, сумма сойдётся. Но только потому, что в учебной базе нет двух платежей одного человека в один день. Корректность держится на свойстве данных, которое никто не обещал. Правильный ключ платежа — payment_id, а правильная форма запроса — не соединять таблицы, если от второй нужен только факт существования. Для этого есть EXISTS: он не размножает строки и не требует DISTINCT.
select count(*) as payments, sum(amount) as revenue
from payments p
where exists (
select 1 from events e
where e.user_id = p.user_id
and e.event_name = 'app_open'
);
-- 1 251 платёж, 30 639Сравните count(*) результата с числом строк в таблице, которая задаёт грейн. Если строк больше, найдите соединение, которое их размножило, и исправьте его: предагрегацией, EXISTS или условием на ключ. DISTINCT поверх такого JOIN удаляет и копии, и настоящие повторы, и отличить одно от другого потом нельзя.
COUNT(DISTINCT) по нескольким столбцам
Иногда уникальность задаётся парой: сколько было пользователе-дней, то есть сочетаний «человек — дата». Запись count(distinct user_id, event_date) работает в MySQL, но в PostgreSQL и DuckDB это ошибка: count принимает один аргумент. Переносимый способ — посчитать строки подзапроса с DISTINCT. В PostgreSQL и DuckDB работает и короткая форма через кортеж: count(distinct (user_id, cast(event_time as date))). На учебной базе оба способа дают 29 788 пользователе-дней.
Склейка колонок в строку — частый обходной путь, и в нём две ловушки. Первая — NULL: оператор || с пустым значением возвращает NULL, и такая строка выпадает из подсчёта. count(distinct channel || country) по пользователям даёт 16 вместо 20: четыре пары с пустой страной пропали. Функция concat пустые значения не теряет.
Вторая ловушка — склейка без разделителя. Пользователь 1 в 12-е число и пользователь 11 во 2-е число дают одну и ту же строку 112. На учебной базе пар «пользователь — число месяца» 28 557, а склейка без разделителя насчитывает 28 219: 338 разных пар слились. Разделитель, которого не бывает в данных, например |, или кортеж вместо строки решают обе проблемы.
-- кортеж: 29 788
select count(distinct (user_id, cast(event_time as date))) from events;
-- подзапрос, работает везде: 29 788
select count(*) from (
select distinct user_id, cast(event_time as date) from events
) t;
-- склейка через || теряет NULL: 16 вместо 20
select count(distinct channel || country) from users;
select count(distinct concat(channel, '|', country)) from users; -- 20Сколько стоит DISTINCT и когда хватит приблизительного подсчёта
Чтобы убрать повторы, базе нужно сравнить каждую строку со всеми остальными. На практике это хеш-таблица или сортировка всего результата. Обе операции блокирующие: пока не прочитана последняя строка, неизвестно, уникальна ли первая. Поэтому DISTINCT поверх большого результата расходует память, а при её нехватке уходит на диск. DISTINCT по широкой строке дороже, чем по одному ключу: сравнивать приходится все колонки.
Практический вывод: оставляйте в DISTINCT только те колонки, по которым действительно нужна уникальность, и снижайте объём до дедупликации — фильтром по периоду, предагрегацией, EXISTS вместо JOIN.
На миллиардах событий точный count(distinct) бывает слишком дорогим, и для дашбордов используют приблизительный подсчёт. В DuckDB это approx_count_distinct, в BigQuery — APPROX_COUNT_DISTINCT, в ClickHouse — uniq. Внутри алгоритм HyperLogLog: значения хешируются, и по длине самой длинной серии ведущих нулей в хешах оценивается, сколько было разных значений. Памяти нужно немного и фиксированный объём, независимо от числа строк.
На маленьких данных это плохой обмен. В версии DuckDB, на которой работает курс, approx_count_distinct(user_id) по всем событиям возвращает 5 582 при точных 4 613, а за неделю — 2 091 вместо 1 851. Для отчёта, где число уходит руководителю, берите точный count(distinct). Приблизительный подсчёт оправдан на больших объёмах, в оперативных дашбордах, где важен тренд, а не последний знак.
Типичные ошибки
Почти все ошибки с DISTINCT не вызывают сообщений об ошибке. Запрос выполняется и возвращает правдоподобное число, поэтому ловить их приходится сверкой.
count(*)вместоcount(distinct user_id)там, где спрашивают про людей: 3 723 события вместо 1 851 пользователя.- Сумма дневных DAU вместо WAU: 3 190 вместо 1 851. Уникальные значения пересчитывают на нужном окне.
sum(distinct amount)для выручки. Функция складывает разные значения суммы, а не платежи: 87 вместо 30 639, потому что тарифов всего три — 19, 29 и 39.DISTINCTпосле JOIN вместо исправления JOIN: теряются настоящие повторы, как 339 продлений в примере выше.DISTINCT ONбезORDER BYили с сортировкой без ключа-страховки: строка в группе выбирается случайно.- Ожидание, что
DISTINCTпосчитает NULL так же, какcount(distinct …): 5 значений против 4. - Склейка колонок через
||без разделителя и без обработки NULL.
Что почитать дальше
Потренируйтесь на той же базе: посчитайте WAU за другую неделю двумя способами и найдите последний платёж каждого пользователя через DISTINCT ON и через row_number(), затем сверьте итоги. Если расхождения есть, ищите их в сортировке и ничьих.
Материалы по теме

SQL GROUP BY и COUNT: как считать пользователей по каналам и дням
Разбираем GROUP BY, COUNT и COUNT DISTINCT на задачах аналитика: пользователи по каналам, DAU по дням и фильтрация агрегатов через HAVING.
Ошибки в SQL-запросах: тексты сообщений, причины и исправления
Частые ошибки SQL с дословными текстами PostgreSQL и DuckDB: GROUP BY, column does not exist, ambiguous, division by zero, типы и даты, и пять запросов, которые молча врут.

LAG и LEAD в SQL: предыдущая и следующая строка на примерах
LAG и LEAD в SQL и PostgreSQL на учебной базе: синтаксис с offset и default, паузы между событиями, DAU к вчера и к прошлой неделе, следующий шаг после события, ничьи в ORDER BY, QUALIFY и IGNORE NULLS.