Short answer: partly. Ruff finds unused code inside one file and one scope: imports a module doesn't use, local variables that are assigned and never read, names redefined before they were used. It doesn't find a function, class or method that nothing in your project calls. That needs a tool that reads every file at once.
The request to add this to Ruff, astral-sh/ruff#872 "Add support for vulture-like dead code detection", has been open since 22 November 2022. On 9 Oct 2026 it had 189 thumbs-up and 16 comments.
What Ruff catches
From the Ruff rules list, the rules for unused code are:
| Rule | Name | What it reports |
|---|---|---|
| F401 | unused-import | An import the file doesn't use |
| F841 | unused-variable | A local variable in a function that is assigned and never used |
| F811 | redefined-while-unused | A name defined again before the first definition was used |
| F842 | unused-annotation | A local variable that is annotated and never used |
| RUF059 | unused-unpacked-variable | An unused name from tuple unpacking |
| B007 | unused-loop-control-variable | A loop variable the body never uses |
| ARG001 to ARG005 | unused-*-argument | Unused function, method, class method, static method and lambda arguments |
These are useful and fast, and Ruff can fix F401 automatically. But every one of them can be decided by looking at a single file. None of them answers "is this function used anywhere?"
A three-file example
# app/billing.py
import os
import json
def compute_total(items):
subtotal = sum(item["price"] for item in items)
discount = 0
return subtotal
def legacy_invoice_format(total):
return json.dumps({"total": total})
# app/main.py
from app.billing import compute_total
def run():
print(compute_total([{"price": 3}]))
if __name__ == "__main__":
run()
legacy_invoice_format is defined and never called anywhere. Ruff 0.15.6:
$ ruff check --select F .
F401 [*] `os` imported but unused
--> app/billing.py:1:8
F841 Local variable `discount` is assigned to but never used
--> app/billing.py:7:5
Found 2 errors.
Skylos 4.47.1 on the same project:
$ skylos . --format concise
app/billing.py:11 SKY-U001 unused function: legacy_invoice_format
app/billing.py:1 SKY-U002 unused import: os
app/billing.py:7 SKY-U003 unused variable: discount
Ruff isn't wrong here. It was never asked the question. Whether legacy_invoice_format is used depends on main.py, on tests, on anything that imports app.billing, and possibly on a framework that calls it by name. A per-file linter can't see any of that, and that design is a big part of why Ruff is so fast.
What needs a whole-project scan
- Unused functions, classes and methods across modules. The core case above.
- Transitive dead code. If function A is dead and it's the only caller of B, then B is dead too. You only see B after you account for A.
- Framework entry points. Flask and FastAPI routes, Django views listed in
urlpatterns, pytest fixtures and Celery tasks are called by the framework, not by your code. A naive whole-project scan reports them as unused. A useful one knows the conventions or lets you declare them. - Dynamic use.
getattr(obj, f"handle_{name}"), plugin registries and string-based imports. No static tool sees all of these, so every option below gives you a way to mark code as used.
Your options next to Ruff
| Vulture | deadcode | Skylos | Runtime coverage | |
|---|---|---|---|---|
| What it is | Dedicated dead-code finder | Dead-code finder with autofix | Dead code plus optional security, secrets and quality checks | Records which lines ran |
| Licence | MIT | AGPL-3.0 | Apache-2.0 | depends on tool |
| Latest release (9 Oct 2026) | 2.16, 25 Mar 2026 | 2.4.1, 9 Aug 2024 | 4.47.1, 8 Oct 2026 | n/a |
| Ranking findings | Confidence 60 to 100% | none | Confidence 0 to 100 with evidence | n/a |
| Framework entry points | Whitelists, --ignore-decorators, --ignore-names | Ignore options | Built in for Flask, FastAPI, Django, pytest and others, plus config | Sees whatever ran |
| Fails CI on findings | Yes, exits 3 | Open issue: exits 0 even when issues are found (#35) | Yes, with --strict | n/a |
| Removes code | No | --fix | skylos clean --dry-run previews; --apply writes | No |
Notes, stated neutrally:
- Vulture is the long-standing choice. Its README gives functions, classes and methods 60% confidence and recommends whitelist files (
vulture mydir --make-whitelist > whitelist.py) for code that is used in ways it can't see. It has a pre-commit hook (id: vulture). - deadcode offers automatic removal. Its last release was August 2024, and two open issues matter before you adopt it: exit code 0 when issues are found, and an error on Python 3.14.
- Skylos builds a project-wide graph, follows dead code transitively, and models common Python frameworks. It can also preview the removals it is sure about. For real-world results, see dead-code cleanup PRs that maintainers merged. Like every static tool, it still needs review on dynamic code.
- Runtime coverage (for example coverage.py during your test run or in a canary) gives evidence no static tool has: this line ran. It only knows about what you exercised, so pair it with a static scan rather than replacing one.
For a side-by-side of the three static tools, see deadcode vs Vulture vs Skylos.
Removing what you find
Review first, then delete. With Skylos, clean previews removals of unused imports and functions without writing anything:
$ skylos clean . --dry-run
Skipping 1 unsupported dead code item (variable). Automatic edits currently
support imports and functions only.
Dry run: would remove 2 dead code items.
app/billing.py
L1 import os (100%)
L11 function legacy_invoice_format (100%)
In non-interactive mode it only acts on findings at confidence 80 or higher by default. skylos clean . --apply writes the edits; with no flag it asks about each one.
A pre-commit setup: Ruff plus Skylos
Keep Ruff for what it's good at, and add a project-wide dead-code check after it:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.10
hooks:
- id: ruff-check
args: [--fix]
- repo: https://github.com/duriantaco/skylos
rev: v4.47.1
hooks:
- id: skylos-scan
args: [".", "--confidence", "70", "--strict"]
Two things to know about the skylos-scan hook:
- Its default arguments only report. The hook is named "skylos (report only)" and never fails a commit as shipped. The
argsabove replace the defaults;--strictmakes the commit fail when there is any finding at or above confidence 70. - It scans the whole repository, not only the staged files, because dead code is a whole-project question. It runs when Python files are part of the commit.
To run the same check on pull requests, generate a GitHub Actions workflow:
skylos cicd init --no-upload
It writes a pull-request gate that needs no Skylos account or API key. --no-upload leaves out the job that uploads scans to Skylos Cloud.
If you have framework code that Skylos still reports, mark it in pyproject.toml instead of lowering the threshold:
[tool.skylos.whitelist]
names = ["handle_*"]
The dead-code docs cover confidence levels, suppressions and entry-point config in detail.
Related
- How to detect dead code in Python
- deadcode vs Vulture vs Skylos
- Find unused Django views, URLs, signals and admin code
- Is there a Knip for Python?
python.lintingis deprecated in VS Code: what to use now
Try it
pip install skylos
skylos .