Все материалы
Продуктовая аналитикагайдстарт

DISTINCT в SQL: уникальные строки, COUNT(DISTINCT) и DISTINCT ON

Как работает DISTINCT в SQL на реальной учебной базе: уникальность по всей строке, DISTINCT по нескольким столбцам, COUNT(DISTINCT) для DAU, NULL, отличие от GROUP BY, DISTINCT ON в PostgreSQL и DuckDB, и почему DISTINCT после JOIN прячет ошибку в выручке.

КейсПрактика24 сентября 2026 г.13 мин

Менеджер просит простую цифру: сколько людей пользовались продуктом на неделе с 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, referral

DISTINCT по нескольким столбцам

Добавьте вторую колонку — и ключ уникальности станет составным. 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 августа520443
4 августа496419
5 августа587485
6 августа594499
7 августа533446
8 августа506457
9 августа487441
Сумма за неделю3 7233 190 — не WAU
Вся неделя одним запросом3 7231 851 — это WAU
Три ответа на вопрос «сколько людей было на неделе»

Неделя 3–9 августа. Строки событий, сумма дневных DAU и уникальные пользователи за всю неделю. Правильный ответ — только последний столбец.

Значение
События и 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 037

DISTINCT 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, 39

DISTINCT 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
Проверка перед тем, как дописать DISTINCT

Сравните 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(), затем сверьте итоги. Если расхождения есть, ищите их в сортировке и ничьих.

Продолжить чтение
Вся библиотека