Сравнение файлов в Python: filecmp, difflib и хеши на практике
Три задачи — «одинаковы ли два файла», «что изменилось построчно» и «найди дубликаты в тысяче файлов» — решаются разными инструментами. Разбираем, что выбрать и чем платишь за каждый вариант.

Задача «сравнить файлы» на самом деле три разные задачи, и попытка решить их одним инструментом даёт либо ложные срабатывания, либо тормоза. Побайтовое сравнение скажет «файлы разные», но не покажет, какие строки поменялись. Построчный diff на бинарнике выдаст мусор. А наивный перебeр «каждый с каждым» для поиска дубликатов в каталоге на 10 000 файлов превратит секунды в минуты.
Разберём три реальные ситуации на стандартной библиотеке Python 3.12 (всё описанное работает начиная с 3.8, отдельно отмечу, где есть нюанс версии). Никаких сторонних пакетов — filecmp, difflib и hashlib закрывают большинство случаев.
Задача 1: «Одинаковы ли два файла»
Самый частый вопрос в скриптах деплоя и бэкапа: изменился ли файл, нужно ли его перезаписывать. Здесь важна только правда «да/нет», содержимое различий не интересует.
filecmp.cmp — быстрый путь
Функция filecmp.cmp(f1, f2, shallow=True) по умолчанию сравнивает не содержимое, а результат os.stat: размер и время модификации. Это и ловушка, и фича.
import filecmp
# shallow=True (по умолчанию): сравнивает только os.stat
# Быстро, но два разных файла с одинаковым размером и mtime
# будут признаны одинаковыми
равны_поверхностно = filecmp.cmp("config_old.ini", "config_new.ini")
# shallow=False: читает и сравнивает содержимое блоками
равны_реально = filecmp.cmp("config_old.ini", "config_new.ini", shallow=False)С shallow=True вы получаете почти бесплатную проверку, но она врёт, если файл перезаписали тем же содержимым другого размера — редко, но бьёт больно. Для деплоя, где важна каждая перезапись, ставьте shallow=False: тогда функция читает файлы блоками и останавливается на первом расхождении.
Правило простое: shallow=True — для «наверное, не менялось» (кеш, инкрементальный бэкап, где ложное «изменился» не страшно). shallow=False — когда цена ошибки высока.
Когда даже shallow=False мало
Если вам нужно сравнить два файла, которые генерируются на разных машинах, и вы не доверяете filecmp, посчитайте хеш содержимого. Для одного сравнения это медленнее (читаете оба файла целиком), но результат воспроизводим и его можно сохранить и сравнить позже, когда второго файла уже нет под рукой.
import hashlib
def хеш_файла(путь, размер_блока=65536):
"""SHA-256 файла, читаем блоками, чтобы не грузить в память целиком."""
h = hashlib.sha256()
with open(путь, "rb") as f:
while блок := f.read(размер_блока):
h.update(блок)
return h.hexdigest()
# Оператор := (walrus) требует Python 3.8+
одинаковы = хеш_файла("dump_a.sql") == хеш_файла("dump_b.sql")Задача 2: «Что именно изменилось»
Вопрос из code review и разбора конфигов: не просто «файлы разные», а какие строки добавили, убрали, поправили. Здесь работает difflib, и ключевой выбор — какую функцию взять.
unified_diff против ndiff
difflib.unified_diff даёт формат, знакомый по git diff: строки с + и -, компактно, только изменённые участки с контекстом. difflib.ndiff показывает все строки и подсвечивает изменения внутри строки, но выдаёт много вывода.
import difflib
with open("nginx_old.conf", encoding="utf-8") as f:
старые = f.readlines()
with open("nginx_new.conf", encoding="utf-8") as f:
новые = f.readlines()
# readlines() сохраняет \n в конце строк — для diff это правильно
дифф = difflib.unified_diff(
старые, новые,
fromfile="nginx_old.conf",
tofile="nginx_new.conf",
lineterm="", # чтобы не задваивать переводы строк
)
print("".join(дифф))Результат — генератор строк, а не список: если файлы огромны, вы не держите весь diff в памяти. Для отчёта в браузере есть difflib.HtmlDiff().make_file(старые, новые) — он рисует таблицу с двумя колонками и подсветкой, но помните, что при больших файлах HTML раздувается в разы больше исходников.
Порог схожести без построчного разбора
Иногда нужен не список различий, а одно число: насколько два текста похожи. Для этого есть SequenceMatcher.ratio() — коэффициент от 0 до 1.
from difflib import SequenceMatcher
текст_a = open("письмо_1.txt", encoding="utf-8").read()
текст_b = open("письмо_2.txt", encoding="utf-8").read()
схожесть = SequenceMatcher(None, текст_a, текст_b).ratio()
if схожесть > 0.9:
print(f"Тексты почти идентичны: {схожесть:.2%}")Важная деталь: ratio() имеет сложность, близкую к квадратичной от длины входа. На двух файлах по мегабайту она задумается надолго. Для быстрой прикидки есть quick_ratio() — верхняя оценка без выравнивания последовательностей, она никогда не завышает результат меньше реального.
Задача 3: «Найди дубликаты среди тысячи файлов»
Классическая задача уборки: каталог загрузок, куда годами падали копии одних и тех же архивов под разными именами. Наивное решение — сравнить каждый файл с каждым — это O(n²) сравнений и O(n²) чтений с диска. Для 1000 файлов это около полумиллиона пар.
Правильный порядок: сначала размер, потом хеш
Дубликаты обязаны иметь одинаковый размер. Размер файла читается из метаданных мгновенно, без чтения содержимого. Сначала группируем по размеру, и хеш считаем только внутри групп, где размер совпал — а таких групп обычно единицы.
import hashlib
from collections import defaultdict
from pathlib import Path
def хеш_файла(путь, размер_блока=65536):
h = hashlib.sha256()
with open(путь, "rb") as f:
while блок := f.read(размер_блока):
h.update(блок)
return h.hexdigest()
def найти_дубликаты(каталог):
# Шаг 1: группируем пути по размеру (дёшево, только метаданные)
по_размеру = defaultdict(list)
for путь in Path(каталог).rglob("*"):
if путь.is_file():
по_размеру[путь.stat().st_size].append(путь)
дубликаты = defaultdict(list)
# Шаг 2: хешируем только там, где размеры совпали
for размер, файлы in по_размеру.items():
if len(файлы) < 2:
continue # уникальный размер — точно не дубль
for путь in файлы:
дубликаты[хеш_файла(путь)].append(путь)
# Оставляем только группы с двумя и более файлами
return {h: пути for h, пути in дубликаты.items() if len(пути) > 1}
for хеш, пути in найти_дубликаты("~/Downloads").items():
print(f"Дубли ({len(пути)} шт):")
for p in пути:
print(f" {p}")Такой двухфазный подход экономит чтение диска на порядок: файлы с уникальным размером вы вообще не открываете. Для дополнительной оптимизации можно добавить третью фазу — хешировать сначала первые 4 КБ, и полный хеш считать только для тех, у кого совпал и размер, и начало. На каталогах с множеством крупных файлов это заметно ускоряет работу.
Что выбрать под задачу
| Задача | Инструмент | Цена |
|---|---|---|
| Менялся ли файл (быстро, ошибка не страшна) | filecmp.cmp shallow=True | Может соврать при совпадении stat |
| Менялся ли файл (точно) | filecmp.cmp shallow=False | Читает содержимое до первого различия |
| Сравнить позже / на другой машине | hashlib SHA-256 | Читает файл целиком, хеш можно сохранить |
| Показать построчные различия | difflib.unified_diff | Только текстовые файлы, память под diff |
| Число «насколько похожи» | SequenceMatcher.ratio | Почти квадратичная сложность |
| Дубликаты в каталоге | размер → хеш, двухфазно | Нужна аккуратная группировка |
Где всё ломается
- difflib на бинарниках.
readlines()для JPEG или ZIP выдаст нечитаемый мусор и, скорее всего, упадёт на декодировании. Проверяйте тип файла до diff — например, что расширение текстовое, или что файл декодируется в выбранной кодировке. - Кодировка. Открывайте текст с явным
encoding="utf-8". Без него Python берёт системную кодировку, и на Windows это часто cp1251 — тот же скрипт даст разный результат на разных машинах. - Хеш-коллизии. Для SHA-256 вероятность случайного совпадения хешей у разных файлов пренебрежимо мала, полагаться на неё можно. А вот MD5 для проверки на дубликаты годится, но не для защиты от подмены — там коллизии подбираются намеренно.
- Символические ссылки.
Path.rglobвернёт и симлинки;stat()по умолчанию идёт по ссылке. Если в каталоге есть циклы симлинков, обход зациклится. Фильтруйтепуть.is_symlink(), если каталог непроверенный.
Что делать дальше
Для скрипта деплоя начните с filecmp.cmp(shallow=False) — этого хватает в 90% случаев, и не тащите хеши, пока не понадобится хранить отпечаток отдельно от файла. Для уборки каталогов возьмите двухфазную схему «размер → хеш» и добавьте третью фазу по первым килобайтам, только если профайлер покажет, что чтение диска стало узким местом. Замерьте время на своём каталоге через time.perf_counter() до оптимизации — иначе будете ускорять то, что и так быстро. Инвалидация и слияние (three-way merge) — отдельная тема, здесь мы только сравнивали.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: difflib — Helpers for computing deltas (Python docs), filecmp — File and directory comparisons (Python docs)
Частые вопросы
Почему filecmp.cmp говорит, что файлы одинаковы, хотя я их менял?
У меня Python 3.7, будет ли работать оператор := в примерах?
difflib выдаёт кракозябры или падает — что не так?
SHA-256 или MD5 для поиска дубликатов?
Как ускорить поиск дубликатов, если файлов десятки тысяч?
Можно ли получить diff как готовую HTML-страницу?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.