Vulture reports framework code as unused because nothing in your code calls it; Flask, Django and pytest do. Fix it with three things, in this order: ignore_decorators for code registered by a decorator, exclude for files Django reads by name, and a whitelist module for everything else. Don't raise --min-confidence instead: it hides real dead code along with the false positives.

Below are configs we tested on a Flask app and a Django app with Vulture 2.16, with the output before and after.


Why Vulture reports live code

Vulture reads your files and reports names that are defined but never used. Its README is clear about the limits: "Due to Python's dynamic nature, static code analyzers like Vulture are likely to miss some dead code. Also, code that is only called implicitly may be reported as unused."

In a web app, a lot of code is only called implicitly:

CodeWho calls it
@app.route / @bp.route views, @app.errorhandler, request hooksFlask, when a request arrives
Function views in urlpatterns, ready(), Command.handle()Django's URL resolver, app registry and manage.py
@receiver signal handlers, @admin.register classesDjango's signal and admin machinery
Settings, middleware listed in MIDDLEWARE, application in wsgi.pyDjango or the WSGI server, by dotted path
@pytest.fixture functionspytest, by argument name
Functions reached through getattr(module, f"handle_{kind}")Your own code, but by a string

Vulture doesn't model any of these, so you describe them in config.


Don't fix it with --min-confidence

Vulture's confidence value depends on the kind of code, not on how likely it is to be dead. From its README: 100% for unused arguments and unreachable code, 90% for imports, 60% for attributes, classes, functions, methods, properties and variables.

So on our Flask app, raising the threshold hid five of the six real dead items:

$ vulture . --min-confidence 80
shop/__init__.py:13: unused variable 'error' (100% confidence)
shop/services.py:3: unused import 'os' (90% confidence)

os is real dead code. error is a false positive: Flask passes the exception to every @errorhandler function, so the argument has to be there.


Flask: before

The sample app has an app factory, a blueprint with two routes, a @bp.before_app_request hook, an @app.errorhandler(404), an @app.template_filter, a Click command, two handlers called through getattr, and a pytest fixture. Six things in it are really dead: legacy_checkout, export_orders_csv, _csv_row, LegacyMailer, an unused import os and an unused local discount.

$ vulture .
shop/__init__.py:12: unused function 'not_found' (60% confidence)
shop/__init__.py:13: unused variable 'error' (100% confidence)
shop/__init__.py:16: unused function 'money' (60% confidence)
shop/notify.py:4: unused function 'handle_email' (60% confidence)
shop/notify.py:8: unused function 'handle_sms' (60% confidence)
shop/services.py:3: unused import 'os' (90% confidence)
shop/services.py:11: unused variable 'discount' (60% confidence)
shop/services.py:15: unused function 'export_orders_csv' (60% confidence)
shop/services.py:27: unused class 'LegacyMailer' (60% confidence)
shop/services.py:28: unused method 'send' (60% confidence)
shop/views.py:9: unused function 'load_user' (60% confidence)
shop/views.py:11: unused attribute 'user' (60% confidence)
shop/views.py:14: unused function 'index' (60% confidence)
shop/views.py:19: unused function 'order_detail' (60% confidence)
shop/views.py:26: unused function 'legacy_checkout' (60% confidence)

Nine of those lines are live code: the two routes, the hook, the error handler and its argument, the template filter, the two getattr handlers, and g.user.

Flask: the config

In pyproject.toml:

[tool.vulture]
paths = ["shop", "tests", "wsgi.py", "vulture_whitelist.py"]
ignore_decorators = [
  "@*.route", "@*.get", "@*.post",
  "@*.before_app_request", "@*.before_request", "@*.after_request",
  "@*.errorhandler", "@*.template_filter", "@*.context_processor",
  "@click.command",
]
ignore_names = ["handle_*"]

And vulture_whitelist.py for the two things no pattern covers:

# Used, but in ways Vulture can't see.
error  # Flask passes the exception to @errorhandler functions
_.user  # flask.g.user, set in load_user() and read in templates

Notes:

  • Wildcards work in decorator patterns. "@*.route" matched @bp.route. A plain "@app.route" would not match blueprint routes.
  • Vulture simplifies decorators with arguments. Per its README, @foo.bar(x, y) is matched as @foo.bar, so "@*.errorhandler" matches @app.errorhandler(404).
  • The patterns this app doesn't use (@*.get, @*.after_request, @*.context_processor and so on) are there for the rest of your app; they did nothing on the sample.
  • There is no @pytest.fixture entry on purpose: Vulture already counts a fixture as used when a test requests it by name (see pytest), and ignoring the decorator would also hide fixtures nothing uses.
  • ignore_names = ["handle_*"] covers the getattr dispatch. Narrow it to your own naming convention.

Flask: after

$ vulture
shop/services.py:3: unused import 'os' (90% confidence)
shop/services.py:11: unused variable 'discount' (60% confidence)
shop/services.py:15: unused function 'export_orders_csv' (60% confidence)
shop/services.py:27: unused class 'LegacyMailer' (60% confidence)
shop/services.py:28: unused method 'send' (60% confidence)
shop/views.py:26: unused function 'legacy_checkout' (60% confidence)

Only dead code is left. The one it still misses is _csv_row, which is only called by the dead export_orders_csv. Vulture counts that call as a use. After you delete export_orders_csv, run Vulture again and it will report _csv_row; the README recommends re-running after each cleanup for this reason.


Django: before

The Django sample (Django 5.2, manage.py check passes) has function and class-based views, a URLconf, a ModelAdmin with a list_display method, a signal receiver connected in AppConfig.ready(), custom middleware, a management command, and a model method used only in a template. Six things are really dead: two unrouted function views (one behind @login_required), an unrouted class-based view, two helpers and an unused import.

Vulture with defaults printed 36 lines: all 6 dead items, an attribute of the dead class-based view, and 29 lines of live code. Among them every setting in settings.py, both urlpatterns, application, ProductAdmin and list_display, ShopConfig.ready, the management command, TimingMiddleware, the signal receiver, and the *args, **kwargs in framework signatures.

Django: the config

This builds on Adam Johnson's "Django: Clean up unused code with Vulture", which is still the best starting point. In pyproject.toml:

[tool.vulture]
paths = ["mysite", "shop", "vulture_whitelist.py"]
exclude = ["*/settings.py", "*/settings/*.py", "*/migrations/*.py"]
ignore_decorators = [
  "@receiver", "@admin.register", "@admin.action", "@admin.display",
  "@register.filter", "@register.simple_tag", "@register.inclusion_tag",
]
ignore_names = [
  "*Config", "*Middleware", "Command", "handle", "add_arguments", "help",
  "ready", "Meta", "urlpatterns", "application", "clean_*",
  "list_display", "list_filter", "search_fields", "model", "template_name",
]

And vulture_whitelist.py:

# Used, but in ways Vulture can't see.
_.price_display  # named in ProductAdmin.list_display
_.price_with_tax  # used in shop/templates/shop/product_detail.html
quantity  # model field, read by the ORM and forms
args, options  # BaseCommand.handle(*args, **options) signature
sender, kwargs  # signal receiver signature

Django: after

$ vulture
shop/signals.py:13: unused function 'rebuild_search_index' (60% confidence)
shop/views.py:2: unused import 'JsonResponse' (90% confidence)
shop/views.py:18: unused function 'old_landing' (60% confidence)
shop/views.py:22: unused function 'old_account' (60% confidence)
shop/views.py:27: unused class 'LegacyReportView' (60% confidence)
shop/views.py:31: unused function '_format_price' (60% confidence)

All six dead items, nothing else. Two cautions:

  • ignore_names is global. "handle" and "model" hide those names everywhere, not just on commands and views. Prefer whitelist entries when a name is common.
  • Templates are invisible to Vulture. Anything used only in a template (like price_with_tax) needs a whitelist entry. Search your templates before deleting a model method or property.

pytest

Vulture handles more of pytest than you might expect. In a separate test with a conftest.py and one test module:

$ vulture .
tests/conftest.py:9: unused function 'reset_db' (60% confidence)
tests/conftest.py:14: unused function 'stale_fixture' (60% confidence)
tests/test_app.py:9: unused method 'helper_never_used' (60% confidence)
  • test_* functions and methods in test files aren't reported. Vulture's source skips them in files under tests/ or test/ and in test*.py or *_test.py files.
  • A fixture that a test requests by argument name counts as used. client wasn't reported, because test_index(client) uses the name.
  • Autouse fixtures and fixtures nothing requests are reported. reset_db is autouse=True, so it is live; stale_fixture really is unused.
  • "@pytest.fixture" in ignore_decorators silences both, including the stale one. Whitelisting autouse fixtures by name is more precise.
  • Scanning tests/ keeps helpers alive. A test that calls a dead helper makes it "used". Vulture's README suggests running on the library and the test suite to find untested code; try one run with and one without tests/.

Keep the whitelist honest

  • Generate a starting point with vulture --make-whitelist > vulture_whitelist.py, then delete the lines that are really dead. The generated file lists every finding, so taking it unedited silences your real dead code too.
  • Comment every entry with who uses it, as above, so the next person can remove it when that use goes away.
  • Run the whitelist with Python occasionally if you write it with real imports: the README notes that this confirms the whitelisted code still exists.
  • Fail CI on new findings: Vulture exits 3 when it finds dead code.

When another tool is less work

A configured Vulture did well on both apps. If maintaining the config is the problem, two alternatives:

  • deadcode has more ignore options and can delete findings with --fix. It is AGPL-3.0, its last release was August 2024, and its pre-commit hook exits 0 even when it finds dead code (#35).
  • Skylos (our tool) has the Flask, Django and pytest conventions built in. On the Flask app it needed two config entries instead of the block above, and it reported the same five dead items. On the Django app it found only four of the six: in 4.47.1 it doesn't report unused class-based views or views behind @login_required, which Vulture did. And where Vulture over-reports getattr dispatch, Skylos can under-report: a reachable getattr(self, f"do_{action}") made it stop reporting unused functions anywhere in our Flask app. See the tested comparison for both apps.