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:

RuleNameWhat it reports
F401unused-importAn import the file doesn't use
F841unused-variableA local variable in a function that is assigned and never used
F811redefined-while-unusedA name defined again before the first definition was used
F842unused-annotationA local variable that is annotated and never used
RUF059unused-unpacked-variableAn unused name from tuple unpacking
B007unused-loop-control-variableA loop variable the body never uses
ARG001 to ARG005unused-*-argumentUnused 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

VulturedeadcodeSkylosRuntime coverage
What it isDedicated dead-code finderDead-code finder with autofixDead code plus optional security, secrets and quality checksRecords which lines ran
LicenceMITAGPL-3.0Apache-2.0depends on tool
Latest release (9 Oct 2026)2.16, 25 Mar 20262.4.1, 9 Aug 20244.47.1, 8 Oct 2026n/a
Ranking findingsConfidence 60 to 100%noneConfidence 0 to 100 with evidencen/a
Framework entry pointsWhitelists, --ignore-decorators, --ignore-namesIgnore optionsBuilt in for Flask, FastAPI, Django, pytest and others, plus configSees whatever ran
Fails CI on findingsYes, exits 3Open issue: exits 0 even when issues are found (#35)Yes, with --strictn/a
Removes codeNo--fixskylos clean --dry-run previews; --apply writesNo

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 args above replace the defaults; --strict makes 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.



Try it

pip install skylos
skylos .