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
Metadata
Release files for dead-code-detector 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| dead_code_detector-0.5.0.tar.gz | 16.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| dead_code_detector-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 37.7 kB
Release files / dead_code_detector-0.5.0.tar.gz
| Download URL | dead_code_detector-0.5.0.tar.gz |
|---|---|
| Size | 16.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
48f631e37c3d1fb8a04e09c3b7fd957a7fa8165b364346e3d2475522f62c3d9e
|
|
BLAKE2b-256 checksum How to use checksums |
17c9e297c28a065c2645d595ab6c5ffbd399525b7980cfa950d869c09cdff4b9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is 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}
|
Release files / dead_code_detector-0.5.0-py3-none-any.whl
| Download URL | dead_code_detector-0.5.0-py3-none-any.whl |
|---|---|
| Size | 21.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b14be0afce68e59d383084ed411ab6f30a398e51a50cc9e2c78858b9422fb2a5
|
|
BLAKE2b-256 checksum How to use checksums |
b0cca44ee1a05ca797e6f464d44ef5bc69e84247c72cb4c1e451bff49c494f1d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is 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}
|