coru

Таблицы и данные на практике

Как найти дубли в CSV и не удалить разные записи с одним ID

Полностью одинаковая запись и повторяющийся ID — разные результаты проверки. Локальный скрипт показывает оба, сохраняет начальные нули и пробелы и не меняет исходный CSV.

Две записи CSV содержат одинаковые значения 001, 10, paid. У двух других записей один ID 002, но разные суммы 20 и 25: это конфликт. Ведущие нули ID сохраняются.
Оригинальный пример Coru. Записи 1 и 2 совпадают во всех разобранных полях: одна группа точных дублей и одна лишняя запись сверх первой. У записей 3 и 4 общий ID 002, но разные значения amount; их нужно проверить. Нумерация записей не включает заголовок CSV; уникальная запись 5 не показана. ID сравниваются как текст, поэтому 001 и 1 остаются различными. Проверка ничего не изменяет и не удаляет. Иллюстрация: Coru — оригинальная схема. Авторские текст и геометрия SVG Coru, локальный рендеринг на основе собственного синтетического CSV-примера; сторонние изображения и снимки программ не использованы.

Сначала определите, что считать дублем

Две записи CSV могут иметь одинаковый ID, но разные суммы, адреса или статусы. Удаление всех повторяющихся ID скроет это расхождение. Начните с двух отдельных проверок: полностью одинаковые записи и повторяющиеся ID, которым соответствуют разные записи.

Здесь точный дубль означает совпадение строковых значений всех разобранных полей в том же порядке столбцов. Это не равенство исходных байтов CSV: 001 и "001" превращаются в одинаковое значение. Конфликтующий ID — непустой идентификатор, которому соответствуют хотя бы две разные полные записи. Является ли расхождение ошибкой, зависит от смысла ID.

1. Попробуйте пример из пяти записей

Сохраните этот текст как sample.csv в UTF-8. Первая запись CSV — заголовок; записи данных нумеруются с 1 после него. ID 001 повторяется с одинаковой суммой и примечанием. У ID 002 две разные суммы. ID 003 встречается один раз.

sample.csv

csv

id,amount,note
001,10,paid
001,10,paid
002,20,pending
002,25,pending
003,30,paid

Сохраняйте эти значения как текст. Начальные нули — часть каждого ID.

2. Запустите локальную проверку без изменения файла

Нужен Python 3. Сохраните полный скрипт ниже как check_csv.py рядом с sample.csv. Дополнительные библиотеки не нужны. Мы проверили примеры в Python 3.12.3 на Linux. Скрипт открывает исходник для чтения и печатает отчёт JSON; очищенный CSV он не создаёт и сетевых запросов не делает.

Используйте локальную копию, которую никто не редактирует во время проверки. Скрипт держит в оперативной памяти разные записи, их номера и группы ID. Потребление памяти растёт вместе с исходником. Начните с небольших выгрузок, которые уверенно помещаются в память; этот способ не рассчитан на файлы произвольного размера.

check_csv.py

python

import argparse
import csv
import json


def inspect_csv(path, key, delimiter):
    rows, keys, empty_ids = {}, {}, []
    total = 0
    with open(path, encoding="utf-8-sig", newline="") as source:
        reader = csv.reader(source, delimiter=delimiter, strict=True)
        header = next(reader, None)
        if not header or any(name == "" for name in header):
            raise ValueError("missing or empty header name")
        if len(set(header)) != len(header):
            raise ValueError("duplicate header name")
        if key not in header:
            raise ValueError(f"ID column not found: {key!r}")
        column = header.index(key)
        for total, fields in enumerate(reader, 1):
            if len(fields) != len(header):
                raise ValueError(
                    f"record {total}, ending on line {reader.line_num}: "
                    f"expected {len(header)} fields, got {len(fields)}"
                )
            row = tuple(fields)
            rows.setdefault(row, []).append(total)
            value = fields[column]
            if value == "":
                empty_ids.append(total)
            else:
                keys.setdefault(value, set()).add(row)
    duplicates = [numbers for numbers in rows.values() if len(numbers) > 1]
    conflicts = [
        {"id": value, "records": sorted(n for row in variants for n in rows[row])}
        for value, variants in keys.items() if len(variants) > 1
    ]
    return {
        "records": total,
        "exact_duplicate_groups": duplicates,
        "extra_exact_records": sum(len(group) - 1 for group in duplicates),
        "conflicting_ids": conflicts,
        "empty_id_records": empty_ids,
    }


def main():
    parser = argparse.ArgumentParser(description="Inspect CSV; do not change it.")
    parser.add_argument("file")
    parser.add_argument("id_column")
    parser.add_argument("--delimiter", choices=[",", ";", "\t"], default=",")
    args = parser.parse_args()
    try:
        report = inspect_csv(args.file, args.id_column, args.delimiter)
    except (OSError, UnicodeError, csv.Error, ValueError) as error:
        parser.exit(1, f"No report: {error}\n")
    print(json.dumps(report, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

Исходник должен быть в UTF-8 с необязательным BOM в начале и иметь заголовок с непустыми, неповторяющимися именами.

Запустите из папки с обоими файлами

bash

python3 -B check_csv.py sample.csv id

Последний аргумент — точное имя столбца с идентификатором, с учётом регистра.

Выберите разделитель явно

По умолчанию разделитель — запятая. Для файла с точкой с запятой добавьте к команде --delimiter ";". Скрипт также принимает настоящий символ табуляции в аргументе разделителя. Для заключённых в кавычки полей используются двойные кавычки; внутренняя двойная кавычка удваивается. Кодировка, разделитель, наличие заголовка и столбец ID не угадываются. Для другой системы кавычек нужна соответствующая настройка парсера.

Отчёт для sample.csv

json

{
  "records": 5,
  "exact_duplicate_groups": [
    [
      1,
      2
    ]
  ],
  "extra_exact_records": 1,
  "conflicting_ids": [
    {
      "id": "002",
      "records": [
        3,
        4
      ]
    }
  ],
  "empty_id_records": []
}

Отчёт появляется только после успешного чтения всего исходника.

3. Читайте два результата отдельно

  • records равно 5: заголовок не учитывается. В exact_duplicate_groups указано [1, 2] — эти две записи полностью совпадают.
  • extra_exact_records равно 1: в группе из двух записей есть одно вхождение сверх первого. Это описание количества, а не разрешение удалить запись.
  • conflicting_ids указывает ID 002 и записи [3, 4]. Посмотрите на столбец amount: значения 20 и 25 различаются. Оставив первую или последнюю запись, вы выберете сумму, ещё не установив, какая верна.
  • empty_id_records здесь пуст. В вашем файле в этот список попадут записи с точно пустым ID; они исключаются из группировки конфликтов по ID. При этом их полные записи участвуют в поиске точных дублей.

Категории могут пересекаться

Если в файле две одинаковые копии 002,20,pending и ещё одна запись 002,25,pending, первые две образуют группу точных дублей, а все три относятся к одному конфликтующему ID. Нельзя складывать длины двух списков, чтобы получить число записей для удаления.

Повторение ID подозрительно, только если этот столбец должен определять одну запись. Код клиента может законно встречаться в нескольких заказах. Если уникальность определяется несколькими столбцами, проверки по одному недостаточно: сначала установите составной ключ, затем решайте, какие расхождения важны.

Какие различия сохраняются

Проверка не обрезает пробелы, не меняет регистр, не нормализует Unicode и не преобразует числа. ID 001 и 1 остаются разными. ID из одного пробела не считается пустым. Строки суммы 10 и 10.0 различаются, как и paid и paid с пробелом в конце. Буквальное сравнение показывает различия оформления, не решая за вас, что ими можно пренебречь.

Запятая или перенос строки внутри кавычек — часть поля. Поэтому запись 3 не обязательно находится на физической строке 4 в редакторе. Символы переноса внутри заключённого в кавычки поля тоже сравниваются буквально: CRLF и LF могут дать конфликт. Необязательный BOM UTF-8 убирается только в начале файла.

Если проверка остановилась

Отсутствующий заголовок, пустое или повторяющееся имя столбца, отсутствие столбца ID, неверное число полей либо недекодируемый UTF-8 останавливают выполнение. Пустая запись CSV вне поля в кавычках отвергается как запись без полей; запись из пустых полей правильной ширины — другой случай. Файл только с заголовком успешно даёт ноль записей данных.

Парсер работает с strict=True, но это не полная проверка всех правил и вариантов CSV. В нашем прогоне незакрытое поле в кавычках и текст после закрывающей кавычки дали ошибку, а кавычка внутри поля без внешних кавычек была принята. Обычный предел размера поля Python также сохраняется. Даже поздняя ошибка приводит к “No report”, а не неполному отчёту JSON. Исправьте исходник или настройки и повторите запуск.

Перед удалением записей

Сохраните оригинал, просмотрите указанные записи и подтвердите рабочий смысл ID. Даже одинаковые записи могут описывать отдельные реальные события. При расхождениях сравните значения с исходной выгрузкой или системой, отвечающей за запись, и установите правило разрешения конфликта. После согласованной очистки отдельной копии повторите проверку и сверьте итоговое число записей. Отчёт без конфликтов доказывает лишь отсутствие разных записей для каждого непустого ID в выбранном столбце при этих правилах буквального сравнения.

Источники и материалы

Помогите улучшить руководство

← Все заметки