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 localsUnused functions and classes across modulesCode the framework callsRemoves codeFails CI on findingsLicence
Ruff / Flake8Yes (F401, F841)Non/aRuff --fix for importsYesMIT
PylintYes (W0611, W0612) plus unused private members (W0238)Non/aNoYesGPL-2.0
VultureYesYes, with a 60–100% confidenceYou configure it: --ignore-decorators, whitelist fileNoYes, exit code 3MIT
deadcodeYesYesYou configure it: ignore options--fixNo, exits 0 (#35)AGPL-3.0
SkylosYesYes, plus transitive dead codeBuilt in for Flask, FastAPI, Django, pytest and others, with gaps listed belowskylos clean (imports and functions)With --strictApache-2.0
coverage.py—Shows lines that never ranSees whatever ranNon/aApache-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 commandDead items found (of 6)Live code reported as dead
Ruff 0.17.0, ruff check --select F,ARG .21
Vulture 2.16, vulture .59
Vulture 2.16, vulture . --min-confidence 8011
deadcode 2.4.1, deadcode .58
Skylos 4.47.1, skylos .53
Skylos 4.47.1, skylos . --confidence 8051

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 the getattr dispatch in notify.py kept the helper alive: Skylos reported _csv_row when we replaced that getattr with 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_request hook that 4.47.1 doesn't recognise, and the two getattr handlers. The handlers scored 75%, so --confidence 80 hides them.
  • Vulture's and deadcode's false positives are the routes, the error handler, the template filter, the request hook and the getattr handlers. 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 80 hid 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:

  1. Raise the threshold for a first pass: skylos . --confidence 80 (default 60).

  2. 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"
    
  3. Whitelist names used dynamically: skylos whitelist 'handle_*' adds the pattern to [tool.skylos.whitelist].

  4. Mark one line: put # skylos: ignore on the def line.

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:

  1. Search for the name as a string. getattr, registries, importlib, settings files and templates call code by name.
  2. Check whether it's public API. If other packages import it, unused-in-this-repo doesn't mean unused.
  3. Read git log -S name. Recently added code may be half-wired, not dead.
  4. Keep each PR small (one module or feature) and run the tests.
  5. 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.route means "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. --trace runs your tests to record dynamic calls, and --coverage only executes tests with --allow-coverage-execution.
  • It has framework gaps. Flask before_app_request hooks are reported as unused, and unused Django class-based views and views behind @login_required are 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 clean only 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.



Try it

pip install skylos
skylos . --confidence 80

No account or config file is needed for a local scan.