Раздел 10 · Databases

Дубликаты строк: найти и удалить

junior~20 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Этот урок открывает серию классических задач с собеседований. Первая из них приходит из жизни: импорт запустили дважды, форма отправилась два раза, и в таблице появились одинаковые строки. Интервьюер хочет увидеть две вещи: что ты умеешь найти повторы и что удаляешь их, не потеряв ни одной нужной строки.

Как спрашивают

В таблице появились дубликаты. Как их найти? Как удалить лишние, оставив по одной строке?

Часто уточняют: «А если у таблицы нет первичного ключа?»

Короткий ответ

Найти повторы помогает GROUP BY по колонкам, которые определяют «одинаковость», с условием HAVING count(*) > 1. Удалить лишние можно через ROW_NUMBER(): пронумеровать строки внутри каждой группы одинаковых и удалить всё, где номер больше единицы. Если ключа нет, в PostgreSQL строку отличает служебная колонка ctid. Удаление делаю в транзакции и сначала смотрю, какие строки уйдут.

Разбор: стопка анкет

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

Раскладка по стопкам это GROUP BY, а «оставить первую» это нумерация внутри стопки.

Первое, что нужно решить: что считать дубликатом. В учебной базе блока у Ольги два заказа по 3000. Полностью одинаковыми их не назовёшь: у них разные id и даты. Если бизнес говорит «один клиент, одна сумма это дубль», то группируем по (customer_id, amount):

SELECT customer_id, amount, count(*) AS n
FROM orders
GROUP BY customer_id, amount
HAVING count(*) > 1;
customer_id | amount | n
------------+--------+---
          1 |   3000 | 2

HAVING здесь фильтрует группы, а не строки, как в уроке про WHERE и HAVING. Чтобы увидеть сами строки-дубли, соединяем результат с таблицей:

SELECT o.*
FROM orders o
JOIN (
  SELECT customer_id, amount
  FROM orders
  GROUP BY customer_id, amount
  HAVING count(*) > 1
) d USING (customer_id, amount)
ORDER BY o.id;
id | customer_id | amount | created_at
---+-------------+--------+-----------
 2 |           1 |   3000 | 2026-01-20
 4 |           1 |   3000 | 2026-02-15

Удаление через ROW_NUMBER

Пронумеруем строки внутри каждой группы одинаковых. Оконная функция ROW_NUMBER() из урока про ранжирование даёт каждой строке номер в её группе:

SELECT id, customer_id, amount,
       row_number() OVER (PARTITION BY customer_id, amount ORDER BY id) AS rn
FROM orders
ORDER BY id;
id | customer_id | amount | rn
---+-------------+--------+---
 1 |           1 |   5000 |  1
 2 |           1 |   3000 |  1
 3 |           2 |   7000 |  1
 4 |           1 |   3000 |  2
 5 |           3 |   1200 |  1

ORDER BY id внутри окна решает, какую строку оставить: здесь самую раннюю. Всё, где rn > 1, лишнее:

BEGIN;

DELETE FROM orders
WHERE id IN (
  SELECT id
  FROM (
    SELECT id,
           row_number() OVER (PARTITION BY customer_id, amount ORDER BY id) AS rn
    FROM orders
  ) t
  WHERE rn > 1
);
-- DELETE 1: ушёл заказ 4

SELECT * FROM orders ORDER BY id;  -- проверяем, что осталось
ROLLBACK;                          -- или COMMIT, если всё верно

Оконную функцию нельзя написать прямо в WHERE у DELETE: окна вычисляются на шаге SELECT, как мы видели в уроке про порядок выполнения. Поэтому нумерация живёт в подзапросе.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.