TL;DR
- 45 of 50 popular Python repos (1,000+ GitHub stars) have at least one dead-code finding in production code, and in the median repo 3.9% of production files are flagged as unreachable.
- In Python, a file nothing imports is often loaded by name: 82 of Django's 100 dead-code candidates are locale files imported from a string at runtime.
- 814 of the 819 files flagged in LangChain are compatibility shims that keep old import paths working for its users.
- vulture finds unused names inside a file and an import-graph pass finds whole files nothing imports; Python projects need both, plus a string search before each delete.
I ran a dead-code pass with repowise over 51 popular Python repositories, each with 1,000+ GitHub stars: Django, FastAPI, Airflow, LangChain, Prefect, paperless-ngx and 45 more. Then I took three of them, cloned them fresh, and read the flagged files one group at a time to see what they really were.
The short version: almost every popular Python repo has code that nothing imports, and in Python "nothing imports this" very often means "something loads this by name". Below are the numbers, what the hand check found, and a way to clean up your own project that takes both facts into account.
What counts as dead code in Python?
Two kinds of finding, on production source files only (tests, generated code, vendored code, build scripts and docs examples are excluded):
- A file flagged as unreachable: no other file imports it, and it is not an entry point.
- An unused export: a public function, class or constant defined in one module that no other module imports.
Each finding has a confidence between 0 and 1. I call 0.7 and above "high confidence".
How much dead code do popular Python projects carry?
Dead-code data exists for 50 of the 51 Python repos.
- 45 of 50 have at least one finding. 45 of 50 have at least one unused export, 42 have at least one file flagged as unreachable, and 37 have at least one high-confidence finding.
- The median repo has 15.7 findings per 100 production files, and 3.9% of its production files are flagged as unreachable.
- Pooled across the 50 repos: 4,074 flagged files and 6,008 unused exports, in 38,285 production files.
Some recognisable names, each measured at its own latest index:
| Repo | Production files | Files flagged unreachable | Unused exports | Indexed |
|---|---|---|---|---|
| fastapi/fastapi | 52 | 0 | 0 | 2026-11-04 |
| tobymao/sqlglot | 185 | 0 | 2 | 2026-10-26 |
| litestar-org/litestar | 325 | 6 | 1 | 2026-11-10 |
| paperless-ngx/paperless-ngx | 443 | 13 | 8 | 2026-10-15 |
| django/django | 870 | 154 | 73 | 2026-07-29 |
| PrefectHQ/prefect | 2,281 | 153 | 166 | 2026-07-17 |
| apache/airflow | 4,387 | 424 | 556 | 2026-08-19 |
| langchain-ai/langchain | 1,714 | 819 | 2 | 2026-11-04 |
FastAPI's own package has no findings at all (the table counts production files only). When I re-ran it on a fresh clone, all 259 candidates were in docs_src/ (the code samples behind its documentation), scripts/ and docs/, none in fastapi/.
The bottom rows are where it gets interesting, because what a flagged file means depends on how the project loads its code. Here is what the flagged files were.
What I found when I checked by hand
I cloned each repo, indexed it with the repowise CLI, listed the findings, and then read the flagged files in groups.
Django: 82 of 100 candidates are locale files loaded by string
On Django at commit 6a8248f, the dead-code pass returned 100 candidates. 82 of them are files like these:
django/conf/locale/ar/formats.py
django/conf/locale/ar_DZ/formats.py
django/conf/locale/az/formats.py
No Python file imports django/conf/locale/ar/formats.py. Django loads it at runtime, by building the module name as a string, in django/utils/formats.py:
for location in format_locations:
for loc in locales:
try:
yield import_module("%s.formats" % (location % loc))
except ImportError:
pass
Delete one of those files and Arabic date formatting silently falls back to the default. The other 18 candidates were eight JavaScript files (the admin's static scripts and a JS template, served to the browser), the Sphinx config, extensions and docs linter under docs/, and three functions in scripts/manage_translations.py that its command-line parser dispatches to.
These are files to keep, and the tool treats them that way. Asking for deletion-ready findings only returns nothing, with the reason:
$ repowise dead-code --safe-only
100 finding(s), none deletion-ready: a finding is deletion-ready only when missing imports are evidence for its language. Review the ranked list without the safe-only filter.
LangChain: 814 files that keep old import paths working
On LangChain at commit 1a41b51, 819 files are flagged as unreachable, and 814 of them sit in one package: libs/langchain/langchain_classic. Nearly all look like this:
from typing import TYPE_CHECKING, Any
from langchain_classic._api import create_importer
if TYPE_CHECKING:
from langchain_community.agent_toolkits.jira.toolkit import JiraToolkit
# Create a way to dynamically look up deprecated imports.
# Used to consolidate logic for raising deprecation warnings and
# handling optional imports.
DEPRECATED_LOOKUP = {"JiraToolkit": "langchain_community.agent_toolkits.jira.toolkit"}
_import_attribute = create_importer(__package__, deprecated_lookups=DEPRECATED_LOOKUP)
def __getattr__(name: str) -> Any:
"""Look up attributes dynamically."""
return _import_attribute(name)
That is a compatibility shim. Code written against an older LangChain still does from langchain.agents.agent_toolkits.jira.toolkit import JiraToolkit, and this file forwards the import to its new home with a deprecation warning. Nothing inside the repo imports it because the importers are LangChain's users.
I sorted all 819 by what they contain: 758 use create_importer like the file above, 48 only re-export names. Of the remaining 13, nine are importable modules of the same package (prompt templates and API documentation strings), two are version.py files kept for backwards compatibility in partner packages, and two sit in an examples folder. Removing any of the package files is a breaking change for users, not a cleanup.
Across the whole repo, one finding is marked ready to delete: an example function, run_calculator_http, that git grep finds nowhere except its own definition.
Airflow: example DAGs, docs configs, and one repeated helper
On Airflow at commit 6a8bc716, the pass returned 791 candidates: 352 flagged files, 436 unused exports and 3 unused workspace packages. I sorted the 352 files:
- 111 are example DAGs under
example_dags/folders. Airflow never imports them. It points its DAG processor at the folder and parses every file in it, inairflow-core/src/airflow/dag_processing/bundles/manager.py:
def _add_example_dag_bundle(bundle_config_list: list[_ExternalBundleConfig]):
from airflow import example_dags
example_dag_folder = next(iter(example_dags.__path__))
- 109 are
docs/conf.pyfiles, one Sphinx config per provider package. Sphinx loads them by path. - 14 are generated OpenAPI client files for the web UI, and most of the rest are other front-end and dev tooling files.
- 59 are copies of a small
version_compat.pyhelper from the provider package template. In those 59 providers,git grepfinds nothing that imports it, so they are the ones worth a look: a few lines each, ready for whenever a provider needs the version check.
So most of Airflow's flagged files are loaded from a folder or by a tool, the same shape as the other repos, and the clear candidates come down to one small template file.
The pattern
In all three, most of the files an import graph cannot reach were reached another way:
- by a module name built from a string (
import_module,__import__, Django'sINSTALLED_APPS, Celery task names), or a folder scanned at runtime (Airflow's DAG folders), - by packaging metadata (console scripts and plugin entry points in
pyproject.toml), - or by code outside the repo (a published library's public import paths).
That is why a good dead-code pass for Python reports confidence and a separate "ready to delete" decision, and why the second step is always a search for the module's dotted name as a string.
Vulture or an import graph: what does each one find?
The tool most Python developers know is vulture. It reads each file's syntax tree and reports names that are defined and never used. On the same Django clone:
$ vulture django/ --min-confidence 80
django\contrib\admindocs\middleware.py:14: unused variable 'view_args' (100% confidence)
django\contrib\admindocs\middleware.py:14: unused variable 'view_kwargs' (100% confidence)
django\contrib\auth\middleware.py:54: unused variable 'view_args' (100% confidence)
django\contrib\auth\middleware.py:54: unused variable 'view_kwargs' (100% confidence)
django\contrib\gis\management\commands\ogrinspect.py:14: unused variable 'option_string' (100% confidence)
django\contrib\gis\management\commands\ogrinspect.py:28: unused variable 'option_string' (100% confidence)
django\core\serializers\xml_serializer.py:506: unused variable 'has_internal_subset' (100% confidence)
django\core\serializers\xml_serializer.py:510: unused variable 'is_parameter_entity' (100% confidence)
django\db\backends\utils.py:94: unused variable 'ignored_wrapper_args' (100% confidence)
django\db\backends\utils.py:107: unused variable 'ignored_wrapper_args' (100% confidence)
django\db\models\enums.py:78: unused variable 'last_values' (100% confidence)
django\utils\inspect.py:138: unused variable 'annotation_format' (100% confidence)
Every one of these is a parameter the function has to keep: a callback signature, an overridden method, or a keyword kept for API compatibility. process_view(self, request, view_func, view_args, view_kwargs) has to accept view_args even if this middleware never reads it. At vulture's default threshold of 60%, the same run prints 2,010 lines, mostly unused variables, methods and classes that Django's own machinery or its users call.
The two checks answer different questions:
| vulture | Import-graph pass | |
|---|---|---|
| Unit | a name inside a file | a whole file, or a name other modules should import |
| Good at | dead branches, leftover variables, unused private helpers | modules nothing reaches, exports nobody imports |
| Blind spot | names used by frameworks through reflection | files loaded by string or from outside the repo |
Use both. They rarely flag the same thing.
How do you find dead code in a Python project?
Start with vulture at a high threshold, and keep a whitelist for the framework hooks it cannot see:
pip install vulture
vulture src/ --min-confidence 80
vulture src/ --make-whitelist > whitelist.py # review it, then:
vulture src/ whitelist.py --min-confidence 80
Then look at whole files and exports. With the repowise CLI, which builds the import graph locally and needs no API key:
pip install repowise
repowise init --yes --no-prose --no-editor-setup
repowise dead-code --min-confidence 0.7
repowise dead-code --safe-only
For every file on that list, before deleting it, search for its dotted module name and its file stem as strings:
$ git grep -n "django.conf.locale" -- 'django/*.py'
django/utils/formats.py:76: format_locations.append("django.conf.locale.%s")
django/utils/translation/__init__.py:270: from django.conf.locale import LANG_INFO
django/utils/translation/trans_real.py:14:from django.conf.locale import LANG_INFO
If anything builds the name dynamically, you will usually find the prefix, as in the first line here. Search pyproject.toml and setup.cfg too, for entry points.
What I would delete first
- Files that are flagged, have no string reference anywhere, and have not changed in a long time. Old and unreferenced is the safest combination.
- Unused private helpers vulture finds at 80% or more inside files you already own. Low risk, small diffs.
- Nothing in a published package's import surface until a major version. Move it behind a deprecation warning first, the way LangChain does.
Delete in small pull requests, one directory or one kind of finding at a time. Run the full test suite on each, plus an import of every module in the package, which catches a deleted file that something still imports by name at load time:
python -c "import importlib, pkgutil, mypkg; [importlib.import_module(m.name) for m in pkgutil.walk_packages(mypkg.__path__, 'mypkg.')]"
If you are choosing a tool, this comparison of dead code detection tools covers vulture, the import-graph approach and the options for other languages. Django's code health page shows the dead-code list from the table above in the browser.
Which loading pattern has bitten you when deleting "unused" Python code: string imports, entry points, or something else?
How I got these numbers
The 51 repos are the Python part of a set of 195 of the most-starred public repositories indexed on repowise (1,000+ GitHub stars each, 253,688 production source files, November 2026); the dead-code numbers for the full set are in the code health 2026 report. Findings are counted at confidence 0.4 and above, on production files only, with each repo measured at its own latest index between May and November 2026, so the table is a snapshot per repo. The hand checks were fresh clones on 10 November 2026: Django 6a8248f (full history), LangChain 1a41b51 and Airflow 6a8bc716 (last 300 commits), FastAPI ba5d72b (last 500 commits), each indexed with repowise init --yes --no-prose --no-editor-setup (repowise 0.60.1), plus vulture 2.16 on Django. A fresh run can differ from the table, because the code and the tool both move between index dates.
FAQ
What does vulture do in Python?
vulture reads each file's syntax tree and reports functions, classes, variables and imports that are defined and never used, each with a confidence score. It works on names, so it does not report a whole module that nothing imports, and it cannot see names that a framework calls through reflection.
Does Ruff find dead code?
Ruff finds unused imports (F401) and unused local variables (F841) inside each file, and commented-out code with its eradicate rules (ERA001). It does not track whether another module ever uses a function or file, so unused exports and unreachable files need vulture or an import-graph tool.
How do I find unreachable code in Python?
For statements that can never run, such as code after a return, pylint reports unreachable (W0101). For whole files that nothing reaches, build the import graph (for example repowise dead-code --min-confidence 0.7), then search for each file's dotted module name as a string before deleting it, because Python code is often loaded by name.
Top comments (0)