SQLPostgreSQLдедупликацияоконные функциианалитика данных

Дедупликация данных в SQL: ROW_NUMBER и DISTINCT ON

2026-07-05 10 мин

Нужно убрать дубли из таблицы и оставить одну «правильную» строку на ключ — обычно самую свежую. Короткий ответ: для этого в PostgreSQL есть DISTINCT ON, и в 90% случаев именно он вам и нужен. ROW_NUMBER() OVER (PARTITION BY ...) — универсальный запасной вариант, который работает в любой СУБД и даёт больше контроля. А вот DISTINCT и GROUP BY для этой задачи чаще всего не подходят: они схлопывают строки, но не умеют выбрать нужную из группы. Разберём, когда какой инструмент, где вас ждут грабли, и что с производительностью.

Сначала договоримся, что такое «дубль»

Это не философский вопрос, а самый частый источник багов. Прежде чем писать запрос, ответьте себе на два вопроса:

Возьмём типичную таблицу событий. Пользователь несколько раз обновлял свой профиль, каждое изменение — отдельная строка:

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-разделе.

Что выбрать: короткая шпаргалка

Производительность: что реально важно

Все методы упираются в одно — сортировку. И 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 или индекса.

По сравнению самих методов:

Ещё одна ловушка производительности — 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 собраны десятки задач ровно на этот паттерн с автопроверкой, а список вопросов с реальных собесов — в разделе интервью и в гайде по собеседованию аналитика.

Закрепи на практике
545 SQL-задач с автопроверкой в браузере. Первые — без регистрации.
Решать задачи на дедупликацию →