Нужно убрать дубли из таблицы и оставить одну «правильную» строку на ключ — обычно самую свежую. Короткий ответ: для этого в PostgreSQL есть DISTINCT ON, и в 90% случаев именно он вам и нужен. ROW_NUMBER() OVER (PARTITION BY ...) — универсальный запасной вариант, который работает в любой СУБД и даёт больше контроля. А вот DISTINCT и GROUP BY для этой задачи чаще всего не подходят: они схлопывают строки, но не умеют выбрать нужную из группы. Разберём, когда какой инструмент, где вас ждут грабли, и что с производительностью.
Сначала договоримся, что такое «дубль»
Это не философский вопрос, а самый частый источник багов. Прежде чем писать запрос, ответьте себе на два вопроса:
- По какому ключу считаем дубли? Полное совпадение всех колонок — это одно. Совпадение по
user_idпри разныхcreated_at— совсем другое. От ответа зависит выбор инструмента. - Какую строку из группы оставляем? Самую свежую? Самую старую? С максимальной суммой? Первую попавшуюся?
Возьмём типичную таблицу событий. Пользователь несколько раз обновлял свой профиль, каждое изменение — отдельная строка:
CREATE TABLE profile_updates (
id bigint,
user_id bigint,
city text,
updated_at timestamptz
);
INSERT INTO profile_updates VALUES
(1, 101, 'Москва', '2026-01-10 09:00'),
(2, 101, 'Санкт-Петербург','2026-03-15 14:30'),
(3, 101, 'Казань', '2026-06-01 11:00'),
(4, 202, 'Новосибирск', '2026-02-20 10:00'),
(5, 202, 'Екатеринбург', '2026-02-20 10:00'), -- та же секунда!
(6, 303, 'Сочи', '2026-05-05 18:45');
Задача: получить актуальный город каждого пользователя — то есть по одной строке на user_id с максимальным updated_at. Обратите внимание на строки 4 и 5: у пользователя 202 два обновления в одну и ту же секунду. Это тот самый tie-break, который ломает наивные решения. К нему вернёмся.
Подход 1: DISTINCT — только для полных дублей
DISTINCT убирает строки, где все выбранные колонки совпадают целиком. Это его единственная работа.
SELECT DISTINCT user_id, city
FROM profile_updates;
Здесь дублей нет — все пары (user_id, city) разные, вернутся все 6 строк. DISTINCT полезен, когда у вас реально задвоились строки при загрузке данных (кривой ETL, повторный INSERT) и нужно схлопнуть точные копии:
-- вычистить полные копии из таблицы-помойки
SELECT DISTINCT *
FROM raw_import;
Ключевая ошибка новичка — попытка выбрать «свежую» строку через DISTINCT:
-- НЕ РАБОТАЕТ так, как вы думаете
SELECT DISTINCT user_id, MAX(updated_at)
FROM profile_updates
GROUP BY user_id;
Здесь DISTINCT вообще ни при чём — работу делает GROUP BY, а DISTINCT просто висит балластом. И главное: вы получите user_id и время, но не получите город этой строки. Это фундаментальное ограничение — DISTINCT не умеет «привязать» остальные колонки к строке с максимумом. Для дедупликации по ключу он не подходит.
Подход 2: GROUP BY — агрегаты, но не «та самая строка»
GROUP BY схлопывает группы и позволяет считать агрегаты. Он честно ответит на вопрос «когда последнее обновление у каждого пользователя»:
SELECT user_id, MAX(updated_at) AS last_update
FROM profile_updates
GROUP BY user_id;
user_id | last_update
---------+---------------------
101 | 2026-06-01 11:00:00
202 | 2026-02-20 10:00:00
303 | 2026-05-05 18:45:00
Но как только вам нужен city из этой строки — начинаются пляски. В отличие от MySQL со старым ONLY_FULL_GROUP_BY=off, PostgreSQL не позволит просто дописать city в SELECT: колонки, которых нет в GROUP BY и которые не обёрнуты в агрегат, дают ошибку. И это правильно — иначе СУБД пришлось бы гадать, какой из трёх городов пользователя 101 показать.
Классический обходной путь — джойн таблицы саму на себя по найденному максимуму:
SELECT p.user_id, p.city, p.updated_at
FROM profile_updates p
JOIN (
SELECT user_id, MAX(updated_at) AS last_update
FROM profile_updates
GROUP BY user_id
) m ON m.user_id = p.user_id
AND m.last_update = p.updated_at;
Работает, но у него две проблемы. Во-первых, таблица читается дважды — сначала для агрегата, потом для основного джойна. Во-вторых, и это критично: для пользователя 202 максимум 2026-02-20 10:00 встречается дважды (Новосибирск и Екатеринбург), и джойн вернёт обе строки. Дубль остался. GROUP BY-подход не имеет встроенного tie-break — вам придётся городить дополнительную логику. Для дедупликации по ключу это тупиковая ветка, если у времени бывают совпадения.
Подход 3: ROW_NUMBER() OVER PARTITION BY — универсальный чемпион
Оконная функция ROW_NUMBER() нумерует строки внутри каждой группы (PARTITION BY) в заданном порядке (ORDER BY). Нумерация всегда строго последовательна — 1, 2, 3 — даже при совпадении значений сортировки. Берём строки с номером 1 — получаем по одной на ключ:
SELECT user_id, city, updated_at
FROM (
SELECT
user_id,
city,
updated_at,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY updated_at DESC, id DESC
) AS rn
FROM profile_updates
) t
WHERE rn = 1;
user_id | city | updated_at
---------+---------------+---------------------
101 | Казань | 2026-06-01 11:00:00
202 | Екатеринбург | 2026-02-20 10:00:00
303 | Сочи | 2026-05-05 18:45:00
Смотрите на пользователя 202: выбрался Екатеринбург, потому что при равном updated_at мы добавили вторичный ключ сортировки id DESC. Это и есть детерминированный tie-break — без него СУБД выбрала бы произвольную строку из двух, и результат мог бы меняться от запуска к запуску. Всегда добавляйте уникальный tie-break в ORDER BY оконной функции. Обычно это первичный ключ или момент вставки.
Почему ROW_NUMBER, а не RANK или DENSE_RANK? RANK присваивает одинаковый ранг строкам с равным ORDER BY — то есть при ничьей по времени обе строки получат ранг 1, и дубль вернётся снова. ROW_NUMBER гарантирует уникальные номера, поэтому для дедупликации нужен именно он. RANK пригодится, если вы намеренно хотите оставить все строки с максимальным значением (например, всех сотрудников с максимальной зарплатой в отделе).
Сила ROW_NUMBER — в гибкости. Хотите оставить самую старую запись? Меняете на ORDER BY updated_at ASC. Хотите строку с максимальной суммой заказа при равенстве по свежести? ORDER BY updated_at DESC, order_amount DESC. Плюс это стандартный SQL — тот же запрос работает в ClickHouse, Snowflake, BigQuery, SQL Server. Если пишете переносимый код или не на PostgreSQL — ROW_NUMBER ваш дефолт. Больше про оконки — в разборе оконных функций, а руками покрутить можно в SQL-тренажёре.
Как удалять дубли этим методом
ROW_NUMBER хорош не только для чтения, но и для физической чистки таблицы:
DELETE FROM profile_updates
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY updated_at DESC, id DESC
) AS rn
FROM profile_updates
) t
WHERE rn > 1
);
Здесь мы удаляем всё, кроме строки №1 в каждой группе — то есть все дубли, кроме самой свежей записи на пользователя. Перед DELETE на бою обязательно прогоните тот же запрос как SELECT, чтобы глазами увидеть, что улетит.
Подход 4: DISTINCT ON — фирменная фича PostgreSQL
DISTINCT ON (выражение) оставляет первую строку для каждого уникального значения выражения. «Первую» — по порядку ORDER BY. Это ровно наша задача, записанная максимально коротко:
SELECT DISTINCT ON (user_id)
user_id, city, updated_at
FROM profile_updates
ORDER BY user_id, updated_at DESC, id DESC;
user_id | city | updated_at
---------+---------------+---------------------
101 | Казань | 2026-06-01 11:00:00
202 | Екатеринбург | 2026-02-20 10:00:00
303 | Сочи | 2026-05-05 18:45:00
Тот же результат, что у ROW_NUMBER, но без вложенного запроса и без служебной колонки rn. Читается почти как человеческая фраза: «по одной строке на каждый user_id, беря самую свежую».
Есть жёсткое правило синтаксиса, о которое все спотыкаются: колонки в DISTINCT ON должны идти первыми в ORDER BY. Сначала user_id (по нему группируем), потом updated_at DESC (по нему выбираем свежую), потом tie-break id DESC. Если поставить ORDER BY updated_at DESC без user_id в начале — получите ошибку SELECT DISTINCT ON expressions must match initial ORDER BY expressions.
И, как в ROW_NUMBER, tie-break обязателен. Без id DESC в конце для пользователя 202 PostgreSQL выбрал бы произвольную из двух строк с одинаковым временем. С ним — результат детерминирован.
Когда DISTINCT ON особенно приятен — это выбор «последней записи на ключ» в отчётах: последний статус заказа, последняя цена товара, текущий баланс на дату. Одна строка запроса вместо трёхэтажной конструкции. Подробнее про DISTINCT ON и оконки — в бесплатном курсе SQL, а десятки задач именно на дедупликацию собраны в тренажёре и SQL-разделе.
Что выбрать: короткая шпаргалка
- Полные копии строк (весь ETL задвоился) →
SELECT DISTINCT *илиDISTINCTпо нужным колонкам. - Только агрегат по группе (счётчики, суммы, максимумы), сама строка не нужна →
GROUP BY. - Одна строка на ключ, PostgreSQL, читаемость важнее переносимости →
DISTINCT ON. Самый короткий и быстрый вариант. - Одна строка на ключ, нужна переносимость / сложная логика выбора / физическое удаление дублей →
ROW_NUMBER() OVER (PARTITION BY ...).
Производительность: что реально важно
Все методы упираются в одно — сортировку. И DISTINCT ON, и ROW_NUMBER требуют упорядочить данные внутри групп, а сортировка большого объёма — это память (work_mem) и, если её не хватает, дорогой merge sort на диске.
Индекс решает почти всё. Составной индекс, повторяющий порядок сортировки, позволяет PostgreSQL брать данные уже отсортированными и пропустить шаг сортировки целиком:
CREATE INDEX idx_dedup
ON profile_updates (user_id, updated_at DESC, id DESC);
Под этот индекс и DISTINCT ON, и ROW_NUMBER работают заметно быстрее — планировщик делает Index Scan и берёт первую строку каждой группы почти бесплатно. Всегда смотрите план через EXPLAIN (ANALYZE, BUFFERS): если видите Sort с большим Disk — не хватает work_mem или индекса.
По сравнению самих методов:
DISTINCT ONvsROW_NUMBERна практике очень близки — оба делают одну сортировку.DISTINCT ONиногда чуть быстрее и почти всегда компактнее в коде.- Self-join через
GROUP BYобычно медленнее обоих — таблица сканируется дважды, плюс джойн. На больших таблицах разница ощутима. DISTINCTпо всем колонкам дороже, чем кажется: сортировка/хеширование идёт по всей ширине строки, а не по узкому ключу.
Ещё одна ловушка производительности — NULL в ключе группировки. NULL в PostgreSQL не равен NULL, но DISTINCT, GROUP BY и PARTITION BY считают все NULL одной группой. Если у вас в user_id бывают NULL — все безымянные строки схлопнутся в одну запись, чего вы, скорее всего, не хотели. Проверяйте ключ на NULL заранее.
Частые вопросы
Почему при дедупликации по времени всё равно остаются дубли?
Почти всегда причина — ничья по времени (tie) без вторичного ключа сортировки. Если у двух строк одинаковый updated_at до секунды, ни ROW_NUMBER, ни DISTINCT ON без tie-break не могут детерминированно выбрать одну. Решение: добавьте уникальную колонку вторым ключом сортировки — ORDER BY updated_at DESC, id DESC. Первичный ключ или момент вставки идеально подходят. Отдельно проверьте, что вы не используете RANK вместо ROW_NUMBER: RANK намеренно даёт одинаковый ранг строкам-близнецам, и обе пройдут фильтр = 1.
DISTINCT ON или ROW_NUMBER — что быстрее?
На большинстве запросов разница в пределах погрешности: оба метода делают одну сортировку по группам. DISTINCT ON часто чуть выигрывает и всегда короче в коде. Но ROW_NUMBER универсальнее: он работает в любой СУБД (DISTINCT ON есть только в PostgreSQL), позволяет держать не только первую строку (rn <= 3 — топ-3 на ключ), и удобнее для DELETE-чистки. Практическое правило: чистый PostgreSQL и «одна свежая строка на ключ» — берите DISTINCT ON; всё сложнее или другая СУБД — ROW_NUMBER. Скорость обоих в первую очередь зависит от наличия составного индекса, а не от выбора между ними.
Как удалить дубли прямо из таблицы, а не просто выбрать?
Через DELETE с подзапросом на ROW_NUMBER: нумеруете строки по ключу, удаляете все с rn > 1, оставляя rn = 1 (самую свежую). Код есть в разделе про ROW_NUMBER выше. Три правила безопасности: сначала прогоните тот же запрос как SELECT, чтобы увидеть, что удалится; делайте это в транзакции (BEGIN ... ROLLBACK для проверки, потом COMMIT); на очень больших таблицах иногда быстрее создать чистую копию через CREATE TABLE new AS SELECT DISTINCT ON (...) ..., чем удалять миллионы строк на месте.
DISTINCT и GROUP BY вообще взаимозаменяемы?
Для простого «получить уникальные значения» — да, SELECT DISTINCT col и SELECT col GROUP BY col дадут одно и то же, и планировщик часто выбирает одинаковый план. Разница появляется, когда нужны агрегаты: GROUP BY умеет COUNT, SUM, MAX, а DISTINCT — нет. Но для нашей задачи (одна строка на ключ с привязкой остальных колонок) не годится ни тот, ни другой без костылей: DISTINCT не выбирает строку, а GROUP BY требует self-join и ломается на ничьих. Поэтому для дедупликации по ключу дефолт — DISTINCT ON или ROW_NUMBER.
Дедупликация — одна из тех тем, что стабильно всплывает и в работе, и на собеседовании: интервьюеры любят таблицу с дублями и просьбу «оставь последнюю запись на пользователя». Если хотите набить руку — на zasqlpython.ru собраны десятки задач ровно на этот паттерн с автопроверкой, а список вопросов с реальных собесов — в разделе интервью и в гайде по собеседованию аналитика.