Run a whole-project scanner (Vulture, deadcode or Skylos: pip install skylos && skylos .), review code your framework calls (routes, fixtures, signals) before deleting, then fail CI on new dead code. Ruff, Flake8 and Pylint only report unused imports and local variables inside one file. None of them reports a function or class that nothing in the project calls.
This page gives the commands, real output from four tools run on the same Flask app, how to deal with false positives, and how to remove code safely. Everything was checked on 10 October 2026.
Python dead code detection tools: which finds what
| Unused imports and locals | Unused functions and classes across modules | Code the framework calls | Removes code | Fails CI on findings | Licence | |
|---|---|---|---|---|---|---|
| Ruff / Flake8 | Yes (F401, F841) | No | n/a | Ruff --fix for imports | Yes | MIT |
| Pylint | Yes (W0611, W0612) plus unused private members (W0238) | No | n/a | No | Yes | GPL-2.0 |
| Vulture | Yes | Yes, with a 60–100% confidence | You configure it: --ignore-decorators, whitelist file | No | Yes, exit code 3 | MIT |
| deadcode | Yes | Yes | You configure it: ignore options | --fix | No, exits 0 (#35) | AGPL-3.0 |
| Skylos | Yes | Yes, plus transitive dead code | Built in for Flask, FastAPI, Django, pytest and others, with gaps listed below | skylos clean (imports and functions) | With --strict | Apache-2.0 |
| coverage.py | — | Shows lines that never ran | Sees whatever ran | No | n/a | Apache-2.0 |
For unused files and dependencies, see Is there a Knip for Python?. For a head-to-head of the three dead-code tools, see deadcode vs Vulture vs Skylos.
Step 1: scan the whole project
pip install skylos
skylos .
Skylos reads every Python file, builds a graph of what references what, and reports definitions nothing reaches. Each finding has a confidence score (the default threshold is 60) and the evidence behind it. One row of the default table output, reflowed to fit:
#3 export_orders_csv shop/services.py:15 Conf 90%
Evidence: likely dead — no static references were found [analyzer] · symbol is not
exported as public API [analyzer] · no package, framework, test, or root entrypoint
evidence [analyzer] · +1
For a one-line-per-finding format, use skylos . --format concise. The equivalent Vulture run is pip install vulture && vulture ..
What four tools found on the same Flask app
We built a small Flask shop with 6 dead items and 11 pieces of live code that Flask, Click or pytest calls for you. The dead code:
# shop/services.py
import csv
import io
import os # dead: unused import
def order_total(items):
discount = 0 # dead: unused local variable
return sum(item["price"] * item["qty"] for item in items)
def export_orders_csv(orders): # dead: nothing calls it
buffer = io.StringIO()
writer = csv.writer(buffer)
for order in orders:
writer.writerow(_csv_row(order))
return buffer.getvalue()
def _csv_row(order): # dead: only called by a dead function
return [order["id"], order["total"]]
class LegacyMailer: # dead: never instantiated
def send(self, to, body):
print(f"mail to {to}: {body}")
The sixth is legacy_checkout(), an old view in shop/views.py that no route points to. The live traps sit next to it: @bp.route views, a @bp.before_app_request hook, an @app.errorhandler, an @app.template_filter, a Click command, two handlers called through getattr(module, f"handle_{kind}"), and a pytest fixture.
Results with each tool's defaults, and with a confidence threshold of 80:
| Tool and command | Dead items found (of 6) | Live code reported as dead |
|---|---|---|
Ruff 0.17.0, ruff check --select F,ARG . | 2 | 1 |
Vulture 2.16, vulture . | 5 | 9 |
Vulture 2.16, vulture . --min-confidence 80 | 1 | 1 |
deadcode 2.4.1, deadcode . | 5 | 8 |
Skylos 4.47.1, skylos . | 5 | 3 |
Skylos 4.47.1, skylos . --confidence 80 | 5 | 1 |
Skylos 4.47.1, default settings:
$ skylos . --format concise
shop/notify.py:4 SKY-U001 unused function: handle_email
shop/notify.py:8 SKY-U001 unused function: handle_sms
shop/services.py:15 SKY-U001 unused function: export_orders_csv
shop/views.py:10 SKY-U001 unused function: load_user
shop/views.py:26 SKY-U001 unused function: legacy_checkout
shop/services.py:3 SKY-U002 unused import: os
shop/services.py:27 SKY-U004 unused class: LegacyMailer
shop/services.py:11 SKY-U003 unused variable: discount
Vulture 2.16, default settings:
$ 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)
What to take from it:
- Every tool missed
_csv_row. Vulture and deadcode don't follow dead code transitively. Skylos does, but here thegetattrdispatch innotify.pykept the helper alive: Skylos reported_csv_rowwhen we replaced thatgetattrwith a dict of handlers, or ran with--no-grep-verify. On a simpler three-function example (below) it reported the whole dead chain. - Skylos's false positives:
load_user, a@bp.before_app_requesthook that 4.47.1 doesn't recognise, and the twogetattrhandlers. The handlers scored 75%, so--confidence 80hides them. - Vulture's and deadcode's false positives are the routes, the error handler, the template filter, the request hook and the
getattrhandlers. That is expected: they don't model Flask, and their docs tell you to configure it. - Raising Vulture's threshold is not a fix. Vulture scores every unused function and class at 60%, so
--min-confidence 80hid five of the six dead items. - After configuration both tools were clean. Vulture with a
[tool.vulture]block and a two-line whitelist, and Skylos with two config entries, each reported the same 5 dead items and no live code.
On a Django version of the test the results flip in one place: a configured Vulture found all 6 dead items, while Skylos found 4 and missed an unrouted class-based view and a view behind @login_required. Details are in the Django dead code guide.
Transitive dead code
If a dead function is the only caller of other functions, they are dead too. On this example Skylos 4.47.1 reports all three, while Vulture 2.16 and deadcode 2.4.1 report only process_refund:
def process_refund(order_id): # nothing calls this
validated = _validate_refund(order_id)
_send_refund_email(validated)
return validated
def _validate_refund(order_id): ...
def _send_refund_email(result): ...
With the other tools, delete the first layer and run them again to find the next one.
Step 2: triage false positives
Some code is called by name, by a decorator, or by a string in a config file. No static tool sees all of it, so plan to record what it can't see.
With Skylos, in order of preference:
-
Raise the threshold for a first pass:
skylos . --confidence 80(default 60). -
Declare a decorator your framework calls, in
pyproject.toml:[[tool.skylos.dead_code.entrypoints]] type = "function" decorators = ["bp.before_app_request"] reason = "Flask runs request hooks itself" -
Whitelist names used dynamically:
skylos whitelist 'handle_*'adds the pattern to[tool.skylos.whitelist]. -
Mark one line: put
# skylos: ignoreon thedefline.
On the Flask app, rules 2 and 3 removed all three false positives and kept all five true findings.
With Vulture, list decorators with --ignore-decorators "@*.route" (wildcards work), and put names it can't see into a whitelist file that you scan with the code. The tested config for this app, plus a Django one, is in Vulture false positives: tested configs.
Step 3: remove it safely
Preview the deletions before you make them (paths shortened, log lines removed):
$ skylos clean . --dry-run
Skipping 2 unsupported dead code items (class, variable). Automatic edits currently support imports and functions only.
Dry run: would remove 4 dead code items.
shop/services.py
L3 import os (100%)
L15 function export_orders_csv (90%)
shop/views.py
L10 function load_user (90%)
L26 function legacy_checkout (90%)
--dry-run writes nothing. --apply writes the same edits without prompting, and with no flag Skylos asks about each one. Look at the list above: it includes load_user, the false positive. Add the config from step 2 first, and review every removal. deadcode's deadcode . --fix --dry works the same way; it has open reports about breaking multiline assignments (#26) and from … import lines (#24).
Before deleting anything by hand:
- Search for the name as a string.
getattr, registries,importlib, settings files and templates call code by name. - Check whether it's public API. If other packages import it, unused-in-this-repo doesn't mean unused.
- Read
git log -S name. Recently added code may be half-wired, not dead. - Keep each PR small (one module or feature) and run the tests.
- Run the scan again. Removing one layer can expose the next.
Step 4: stop new dead code in CI
Ruff for per-file checks, Skylos for the whole project, in .pre-commit-config.yaml:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.17.0
hooks:
- id: ruff-check
- repo: https://github.com/duriantaco/skylos
rev: v4.47.1
hooks:
- id: skylos-scan
args: [".", "--confidence", "70", "--strict"]
As shipped, the skylos-scan hook only reports. The args above replace its defaults so that --strict fails the commit on any finding at confidence 70 or higher. It scans the whole repository, not only staged files.
For pull requests, skylos cicd init --no-upload writes a GitHub Actions workflow that needs no Skylos account. To check only changed lines, run skylos . --diff-base origin/main --diff origin/main --strict.
Exit codes, so you can wire your own CI (Skylos 4.47.1, checked on the Flask app): skylos . exits 0 with findings in the default table output, and so do --format json and --format pretty. --format concise exits 1 when it prints findings. --strict exits 1 when anything is reported, in any format. --gate alone exits 0 here, because the default gate sets no dead-code limit. Vulture exits 3 when it finds dead code; deadcode exits 0.
Why grep, "find usages" and coverage miss things
- grep and IDE "find usages" answer one name at a time. They count a call from dead code as a use, and they don't know that
@bp.routemeans "called by Flask". - Coverage only knows what ran. If your tests call a dead helper, it looks alive; if a live path is untested, it looks dead.
- Per-file linters can't see other modules, by design. That's also why they're fast enough to run on every keystroke.
What Skylos does not do
So you know where to look by hand (all checked on 4.47.1):
- It doesn't parse templates. Jinja and Django templates aren't read, including
{% url %}tags, so a method used only in a template looks unused. - It doesn't report a Python module that nothing imports. It reports that module's unused functions and classes, not the file. chokkin can.
- It doesn't run your code in a normal scan.
--traceruns your tests to record dynamic calls, and--coverageonly executes tests with--allow-coverage-execution. - It has framework gaps. Flask
before_app_requesthooks are reported as unused, and unused Django class-based views and views behind@login_requiredare not reported. - It goes quiet around dynamic dispatch. When reachable code calls
getattr(self, f"do_{action}"), 4.47.1 stops reporting unused functions anywhere in the project, with no warning; imports, classes and variables are still reported. On our Flask app, adding one such call hid all three dead functions. A dict of handlers (HANDLERS[kind](message)) avoids this, and Vulture and deadcode follow it too. skylos cleanonly removes imports and functions, not classes or variables.
Where other tools are better
- Vulture is small, MIT-licensed, maintained (2.16, March 2026) and exits non-zero by default. Configured, it was as clean as Skylos on our Flask app and found more on our Django app.
- deadcode can remove classes and variables as well as functions and imports, if you can accept AGPL-3.0 and no release since August 2024.
- Ruff gives instant feedback in the editor and fixes unused imports. Keep it; it doesn't overlap much with a whole-project scan.
- coverage.py gives runtime evidence that no static tool has.
- chokkin and deptry cover unused files and dependencies. See the Knip for Python comparison.
Results on real projects
We sent dead-code cleanup PRs to open-source projects. Of 26 PRs opened in 22 projects, 10 dead-code PRs were merged into default branches across 9 projects, including Black, NetworkX, Optuna, mitmproxy, pypdf and pdm. For Black, Skylos flagged unused internal helpers; we sent PRs, and Black's maintainers reviewed and merged them. Others were closed, reverted or are still open. A merged PR is not an endorsement or adoption of Skylos. The full record, misses included, is in dead-code cleanup PRs that maintainers merged.
Related
- deadcode vs Vulture vs Skylos: tested comparison
- Does Ruff detect dead code?
- Vulture false positives: tested configs for Flask, Django and pytest
- Django dead code: find unused views, helpers and methods
- Is there a Knip for Python? Unused files and dependencies
- Why dead code is a security liability
- Dead-code detection docs
Try it
pip install skylos
skylos . --confidence 80
No account or config file is needed for a local scan.