Мёртвый код в Python: как находить и убирать без регрессий
Неиспользуемые функции, забытые ветки и импорты копятся годами. Разбираем, чем их искать в Python 3.11+, почему статический анализ ошибается и как удалять код, не роняя прод.

В кодовой базе на 120 тысяч строк функция calculate_legacy_discount не вызывалась с 2021 года, но её никто не трогал: страшно. Похожих мест — десятки, и каждое тянет за собой импорты, тесты и мёртвые ветки. Разберём, как в Python найти реально неиспользуемый код, почему инструменты дают ложные срабатывания на динамике и как удалять безопасно, чтобы через неделю не ловить AttributeError в проде.
Статья про Python 3.11–3.13 и типичный веб-бэкенд (Django/FastAPI). Инвалидация зависимостей между сервисами — отдельная тема, здесь только код внутри одного репозитория.
Что вообще считать мёртвым кодом
Смешивают три разные вещи, и от их различия зависит, насколько безопасно удаление.
- Недостижимый код — то, до чего управление не доходит физически: строки после
return, веткаif False:,exceptдля исключения, которое не бросается. Удаляется почти без риска. - Неиспользуемые определения — функции, классы, методы, переменные, на которые нет ссылок. Здесь начинается серая зона: ссылка может быть динамической.
- Мёртвые фичи — код работает и вызывается, но за флагом, который никто не включает. Инструментами не найти, только знанием продукта.
Автоматика надёжно ловит первые две категории. Третью — нет, и это стоит держать в голове, чтобы не переоценить отчёт линтера.
Почему статический анализ в Python ошибается
Python разрешает вызвать метод по строке имени: getattr(obj, "handle_" + action)(). Для анализатора это не ссылка на handle_refund — он видит только getattr с неизвестным аргументом. Отсюда классические ложные срабатывания:
- вызовы через
getattr,__getattribute__,globals()[name]; - обработчики, которые находит фреймворк по имени или декоратору (Django-сигналы, Celery-таски, pytest-фикстуры);
- сериализация: поле dataclass не вызывается в коде явно, но читается через
asdictили ORM; - методы, переопределяющие базовый класс, —
save,__str__,cleanвызываются фреймворком, не вашим кодом.
Чем искать: три инструмента и их цена
Ни один не даёт ответа «удаляй смело». Каждый сужает область поиска, а решение остаётся за вами.
Vulture — быстрый первый проход
Ставится в одну команду и даёт список кандидатов с оценкой уверенности от 0 до 100.
# Python 3.11+, vulture 2.x
pip install vulture
# Отчёт по пакету app, порог уверенности 80%
vulture app/ --min-confidence 80Вывод — строки вида app/services.py:42: unused function 'calculate_legacy_discount' (90% confidence). Проблема в том, что vulture не знает про ваш фреймворк: он пометит вьюхи, сигналы и Celery-таски как мёртвые. Решается файлом-исключением: сгенерируйте заготовку и вычистите ложные срабатывания вручную.
# Создаём whitelist из имён, которые vulture ошибочно счёл мёртвыми
vulture app/ --make-whitelist > whitelist.py
# Дальше запускаем с ним — эти имена больше не в отчёте
vulture app/ whitelist.py --min-confidence 80Ruff — недостижимый код и неиспользуемые импорты
Ruff (проверялось на 0.5.x) быстрее flake8 и ловит неиспользуемые импорты (F401) и локальные переменные (F841) на лету. Он не находит неиспользуемые функции целиком — это не его задача, — но чистит импорты автоматически и без ложных срабатываний на динамике внутри одного модуля.
# ruff 0.5.x
pip install ruff
# Показать неиспользуемые импорты и переменные
ruff check app/ --select F401,F841
# Убрать автоматически то, что безопасно
ruff check app/ --select F401 --fixТочный список кодов правил и их поведение сверяйте в документации Ruff вашей версии — набор правил меняется между релизами.
Coverage под интеграционными тестами — самый честный сигнал
Статика гадает по ссылкам, покрытие показывает, что реально исполнялось. Если у вас есть интеграционные тесты, прогоняющие основные сценарии, отчёт coverage покажет строки, которые не выполнились ни разу.
# coverage 7.x
pip install coverage
# Собрать данные под pytest
coverage run -m pytest
# Строки, которые ни разу не исполнились
coverage report --show-missingЦена метода: он честен ровно настолько, насколько полны ваши тесты. Строка без покрытия не значит «мёртвая» — значит «тесты сюда не заходят». Это повод посмотреть глазами, а не удалять по отчёту.
| Инструмент | Что находит | Ложные срабатывания | Когда применять |
|---|---|---|---|
| Ruff | Импорты, локальные переменные | Почти нет | В CI постоянно, с автофиксом |
| Vulture | Функции, классы, методы, атрибуты | Много на фреймворках | Разовые чистки с whitelist |
| Coverage | Реально неисполнявшийся код | Зависит от полноты тестов | Проверка кандидатов перед удалением |
Ни один отчёт не даёт права удалять без проверки. Инструмент отвечает на вопрос «где смотреть», ответ «можно ли убрать» вы даёте сами.
Как удалять безопасно: порядок действий
Ключевая идея — не удалять сразу. Сначала сделать код видимо мёртвым, дать проду время подтвердить, что его никто не зовёт, и только потом вырезать.
- Соберите кандидатов из трёх источников. Пересечение отчётов vulture, coverage и grep по имени сужает список до реальных подозреваемых. Функция, которую не нашёл ни один вызов ни в одном источнике, — надёжный кандидат.
- Проверьте динамику руками. Прогоните
grep -rn "имя_функции"по всему репозиторию, включая шаблоны, конфиги, миграции и строковые вызовы. Отдельно проверьте, не переопределяет ли метод базовый класс фреймворка. - Пометьте, а не удаляйте. Оберните подозрительный вызов логированием или предупреждением, не меняя поведения.
import logging import warnings logger = logging.getLogger(__name__) def calculate_legacy_discount(order): # Помечено к удалению 2025-02: считаем, вызывается ли на проде logger.warning("DEPRECATED call: calculate_legacy_discount, order=%s", order.id) warnings.warn("calculate_legacy_discount deprecated", DeprecationWarning, stacklevel=2) return _old_discount_logic(order) - Выкатите и подождите. Дайте пройти полному бизнес-циклу — неделя, месяц, квартальный отчёт, если функция может звonеть раз в квартал. Если в логах ноль вызовов, код действительно мёртв.
- Удаляйте одним атомарным коммитом. Функция, её тесты, импорты и связанные фикстуры — вместе. Так откат чистый: один
git revertвозвращает всё.
Что удалять вместе с функцией
- тесты, которые проверяли только её;
- импорты, которые теперь стали неиспользуемыми (прогоните Ruff с
--fixпосле удаления); - фикстуры и моки, созданные ради неё;
- записи в реестрах, роутерах,
__all__, конфигах.
Что ломается чаще всего
Граничные случаи, на которых «безопасное» удаление превращается в инцидент.
- Импорт ради побочного эффекта.
import app.signalsвapps.pyDjango выглядит неиспользуемым — Ruff предложит убрать. Но именно этот импорт регистрирует обработчики сигналов. Удалите — сигналы перестанут ловиться, без единой ошибки в момент старта. - Метод из публичного API библиотеки. Если ваш код — устанавливаемый пакет, «неиспользуемая» функция может вызываться в чужих проектах. Внутри репозитория ссылок нет, но удаление сломает чужой импорт.
- Ветка под фиче-флагом. Coverage покажет её без покрытия, если флаг выключен в тестовом окружении. Это не мёртвый код, а выключенная фича.
- Строковые ссылки в конфигах. Django-миграции ссылаются на функции по пути строкой. Grep по имени найдёт, а vulture — нет. Удалите функцию, на которую ссылается старая миграция, —
makemigrationsили прогон миграций упадёт.
С чего начать на своём проекте
Не запускайте vulture по всей базе в первый день — вы утонете в ложных срабатываниях. Начните с малого и измеримого:
- включите Ruff с правилами
F401,F841в CI прямо сейчас — это чистит импорты без риска и не даёт мёртвому коду копиться дальше; - выберите один модуль, который давно не трогали, и прогоните по нему связку vulture + coverage;
- заведите практику: удаление мёртвого кода — отдельный PR, не примешивается к фиче;
- измеряйте результат числом строк и числом импортов, а не ощущением «стало чище» — так виден прогресс и легче обосновать время на чистку.
Мёртвый код опасен не тем, что занимает место, а тем, что его читают, поддерживают и боятся тронуть. Каждая удалённая неиспользуемая функция — это тесты, которые не нужно чинить, и ревью, которое проходит быстрее.
Таможенный брокер (ВЭД): Коды ТН ВЭД, маркировка «Честный ЗНАК», сертификация.
Спросить за 20 ₽Источники: Vulture — find dead code (GitHub), Ruff documentation — rules
Частые вопросы
У меня Python 3.9, а не 3.11+ — приёмы сработают?
Vulture пометил все мои Django-вьюхи как мёртвые. Что делать?
Можно ли доверять отчёту coverage как доказательству, что код мёртв?
Удалил функцию, а через месяц она понадобилась — это катастрофа?
Как поймать код, который выглядит используемым, но реально мёртв?
Ruff предлагает убрать импорт, который явно нужен для сигналов. Как быть?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.