Find unused functions, methods, and classes in Python codebases
Project description
dead-code-detector
Find unused functions, methods, and classes in Python codebases by walking import and call graphs from user-specified entry points.
Quick start
uv add --dev dead-code-detector
dead-code-detector --src-dir src myapp.main
How it works
The tool uses a hybrid approach: runtime import to discover definitions, AST analysis to find references, then BFS reachability from entry points.
- Import the entry point modules. All transitively imported modules are
discovered via
sys.modules. - Index every function, method, and class using
inspect, with an AST fallback for decorated objects (e.g. Click commands). - Parse every discovered module's AST to extract references: imports, name usage, attribute access, type annotations, base classes.
- Build a directed graph and run BFS from the entry points. Anything not reached is reported as dead code.
The tool overapproximates by design: when unsure whether a reference resolves to a definition, it assumes it does. This means fewer false positives (reporting live code as dead) at the cost of some false negatives (missing truly dead code).
Usage
dead-code-detector [OPTIONS] [ENTRY_POINTS...]
ENTRY_POINTS are one or more Python module paths. The tool imports them and
analyzes everything they transitively import. Entry points can also be set
in pyproject.toml (see Configuration below).
# Single entry point
dead-code-detector --src-dir src myapp.main
# Multiple entry points
dead-code-detector --src-dir src myapp.cli myapp.worker
# Exclude modules from analysis
dead-code-detector --src-dir src --exclude migrations myapp.main
# Codebases with decorator-based registration (@register, @command, etc.)
dead-code-detector --live-decorators --src-dir src myapp.main
Report types
Select the report type with --report (default: flat).
--report flat (default) — grouped by module, methods under their
class, sorted by most dead first:
UNUSED ROUTINES (11 found):
── myapp.models (3/7 dead, 43% unused)
myapp/models.py:21 OldModel [entirely unused]
:24 .__init__()
:27 .process()
myapp/models.py User (class is used, methods dead):
:16 .legacy_export()
── myapp.utils (2/3 dead, 67% unused)
myapp/utils.py:13 helper_unused()
myapp/utils.py:18 another_unused()
--report verbose (or -v) — adds line counts and cross-references
between dead items.
--report stats — per-module coverage table sorted by most dead:
MODULE COVERAGE:
Module Definitions Reachable Dead Coverage
------------------------------------------------------------------
myapp.utils 3 1 2 33.3%
myapp.models 7 4 3 57.1%
myapp.main 2 2 0 100.0%
------------------------------------------------------------------
TOTAL 12 7 5 58.3%
--report usage — shows every definition with its reachability
status and the shortest call path from the entry point:
USAGE MAP (7/12 reachable definitions):
── myapp.models
+ User via: myapp.main
+ .__init__() via: myapp.main -> myapp.models:User
+ .greet() via: myapp.main -> myapp.main:run
- .legacy_export()
- OldModel
- .__init__()
- .process()
+ marks reachable definitions, - marks dead ones. The via: chain shows
the shortest path through the call graph.
--report json — machine-readable output for tooling.
CI integration
Use --exit-code to fail the build when dead code is found:
dead-code-detector --exit-code --src-dir src myapp.main
Exit codes: 0 no dead code, 1 dead code found, 2 error.
Options reference
| Option | Description |
|---|---|
--src-dir DIR |
Source directory to add to sys.path |
--report TYPE |
Report type: flat, verbose, stats, usage, json |
-v |
Shorthand for --report verbose |
--exclude PATTERN |
Exclude modules matching pattern (repeatable) |
--live-decorators |
Treat all decorated definitions as live |
--include-dunders |
Report unreachable dunder methods |
-o, --output FILE |
Write to file instead of stdout |
--exit-code |
Exit with code 1 if dead code is found |
Configuration
Project-level defaults can be set in pyproject.toml so you don't have to
repeat options on every invocation:
[tool.dead-code-detector]
entry-points = ["myapp.cli", "myapp.worker"]
src-dir = "src"
report = "flat"
exclude = ["migrations", "conftest"]
live-decorators = true
exit-code = true
With this config, you can just run dead-code-detector with no arguments.
CLI arguments always override config values. For lists (entry-points,
exclude), CLI values replace the config (not append).
What it handles
- Imports:
import x,from x import y, relative imports - Function and method calls:
foo(),obj.method(),self._helper() - Attribute access chains:
self._checker.is_ready()resolved by method name across all classes (overapproximation) - Dict/list dispatch:
HANDLERS = {"start": _handle_start}— functions stored as values are treated as referenced - Polymorphism: subclass method overrides are linked to base class methods via class hierarchy edges
- Type annotations:
def foo(x: SomeClass)counts as a reference toSomeClass - Decorators and base classes: decorator expressions and base class names are tracked as references
- Private methods:
_underscore_prefixednames are indexed and resolved - Auto-generated methods: dataclass/attrs-generated dunders are excluded (source file check)
- Click commands: decorated functions transformed into non-function objects are discovered via AST fallback
- Decorator-based registration (with
--live-decorators): classes and functions decorated with@register,@command,@hookimpl, or any other decorator are treated as live entry points. Their methods are also marked reachable.
Known limitations
- Dynamic dispatch:
getattr(obj, name)()is not traced. - String references:
globals()["func"]()is not traced. - Framework registration: Flask routes, Django views, Celery tasks are
not automatically detected. Use
--excludeor specify them as entry points. TYPE_CHECKINGimports: modules imported only insideif TYPE_CHECKING:blocks are not imported at runtime and their references are not scanned.- Lazy imports: imports inside function bodies don't execute at module import time, so lazily-imported modules may not be discovered.
- Star imports:
from module import *treats all public names as referenced (overapproximation). You shouldn't use start imports anyway.
Development
Setup
git clone https://git.sr.ht/~sfermigier/dead-code-detector
cd dead-code-detector
uv sync
Project structure
src/dead_code_detector/
cli.py # CLI (argparse + pyproject.toml config)
discovery.py # Runtime import, module discovery via sys.modules
indexer.py # Definition extraction (inspect + AST fallback)
ast_analyzer.py # AST visitor for references
graph.py # Edge resolution, polymorphic edges, BFS reachability
reporter.py # Output formatting (flat, verbose, stats, usage, JSON)
models.py # Data classes (DefinitionInfo, Reference, AnalysisResult)
Pipeline
cli.py
├── discovery.import_entry_points() # import, collect sys.modules
├── indexer.index_modules() # inspect + AST fallback
├── ast_analyzer.analyze_references() # AST parse for references
├── graph.build_graph_and_analyze() # resolve edges, BFS
│ ├── _resolve_edges() # references → definition FQNs
│ ├── _add_polymorphic_edges() # base → subclass overrides
│ └── _compute_reachability() # BFS, record paths
└── reporter.report_*() # format output
Running tests
uv run pytest # all tests
uv run pytest tests/a_unit/ # unit tests only
uv run pytest tests/b_integration/ # integration + regression tests
Or just make test.
Linting and type checking
uv run ruff check src/ tests/
uv run ruff format src/ tests/
uv run ty check src
Or just make lint.
License
MIT
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file dead_code_detector-0.5.0.tar.gz.
File metadata
- Download URL: dead_code_detector-0.5.0.tar.gz
- Upload date:
- Size: 16.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.1 {"installer":{"name":"uv","version":"0.11.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
48f631e37c3d1fb8a04e09c3b7fd957a7fa8165b364346e3d2475522f62c3d9e
|
|
| MD5 |
bd1f9840181c52ce4529a86de9d66633
|
|
| BLAKE2b-256 |
17c9e297c28a065c2645d595ab6c5ffbd399525b7980cfa950d869c09cdff4b9
|
File details
Details for the file dead_code_detector-0.5.0-py3-none-any.whl.
File metadata
- Download URL: dead_code_detector-0.5.0-py3-none-any.whl
- Upload date:
- Size: 21.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.1 {"installer":{"name":"uv","version":"0.11.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b14be0afce68e59d383084ed411ab6f30a398e51a50cc9e2c78858b9422fb2a5
|
|
| MD5 |
77a6a9e93dd3009e0939d31ec3eb0f55
|
|
| BLAKE2b-256 |
b0cca44ee1a05ca797e6f464d44ef5bc69e84247c72cb4c1e451bff49c494f1d
|