<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: repowise</title>
    <description>The latest articles on DEV Community by repowise (repowise).</description>
    <link>https://dev.to/repowise</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F15994%2Fccab109a-d9b5-49a1-b288-a2d4f30e8cff.jpeg</url>
      <title>DEV Community: repowise</title>
      <link>https://dev.to/repowise</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/repowise"/>
    <language>en</language>
    <item>
      <title>Bus factor 1 is the default: per-language ownership in 195 popular repos</title>
      <dc:creator>Raghav Chamadiya</dc:creator>
      <pubDate>Sat, 14 Nov 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/repowise/bus-factor-1-is-the-default-per-language-ownership-in-195-popular-repos-54kh</link>
      <guid>https://dev.to/repowise/bus-factor-1-is-the-default-per-language-ownership-in-195-popular-repos-54kh</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bus factor is the fewest people who together made 80% of a file's commits; a bus factor of 1 means one person made 80% or more of them.&lt;/li&gt;
&lt;li&gt;In the median of 195 popular open-source repos (1,000+ GitHub stars), 60% of production files have a bus factor of 1, and in 124 of the 195 those files are the majority.&lt;/li&gt;
&lt;li&gt;Busy files are more shared: in the median repo, 25% of hotspot files have a bus factor of 1.&lt;/li&gt;
&lt;li&gt;Numbers come from repowise's index of those 195 repos (November 2026), plus a &lt;code&gt;git shortlog&lt;/code&gt; script run on a full clone of Django.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bus factor (also called truck factor) is the number of people who would have to disappear before nobody left understands a piece of code. A bus factor of 1 means one person holds it.&lt;/p&gt;

&lt;p&gt;It sounds like a warning sign. Across 195 popular open-source repositories, it is the normal state of most files.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you calculate bus factor?
&lt;/h2&gt;

&lt;p&gt;For a single file: the fewest authors who together made 80% of its commits. If one person made 80% or more of the commits, the file has a bus factor of 1. If it takes two people to reach 80%, it is 2, and so on.&lt;/p&gt;

&lt;p&gt;Commits are a rough proxy for knowledge, but every repo already has them. &lt;code&gt;git blame&lt;/code&gt; is worse for this, because a formatter run or a big rename hands whole files to whoever pressed the button.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compute it for one file
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git shortlog -sn&lt;/code&gt; counts commits per author and sorts them, highest first. Walk down that list until you pass 80%:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# busfactor.sh &amp;lt;file&amp;gt; [git log options]&lt;/span&gt;
&lt;span class="nv"&gt;f&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"$1"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;shift&lt;/span&gt;
git shortlog -sn --no-merges &lt;span class="s2"&gt;"$@"&lt;/span&gt; HEAD -- &lt;span class="s2"&gt;"$f"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;
  awk &lt;span class="s1"&gt;'{c[NR] = $1; t += $1}
       END {for (i = 1; i &amp;lt;= NR; i++) {s += c[i]; if (s &amp;gt;= 0.8 * t) {print i; exit}}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a full clone of Django (HEAD &lt;code&gt;6a8248f&lt;/code&gt;, 10 November 2026), for the QuerySet module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ./busfactor.sh django/db/models/query.py
54
$ ./busfactor.sh django/db/models/query.py --since="2 years ago"
20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifty-four people made 80% of the commits to &lt;code&gt;query.py&lt;/code&gt; over 21 years, and 20 people over the last two. That is about as far from 1 as a file gets.&lt;/p&gt;

&lt;p&gt;Two details matter in that script. &lt;code&gt;HEAD&lt;/code&gt; is explicit because &lt;code&gt;git shortlog&lt;/code&gt; with no revision reads standard input when it is not attached to a terminal, which inside a script means it prints nothing. And the &lt;code&gt;--since&lt;/code&gt; option is not decoration. The people who wrote a file in 2012 are not necessarily the people who could fix it today, so the window you pick changes the answer.&lt;/p&gt;

&lt;p&gt;Run it over every &lt;code&gt;.py&lt;/code&gt; file under &lt;code&gt;django/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git ls-files &lt;span class="s1"&gt;'django/*.py'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; -r f&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"$(./busfactor.sh "&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;") $f"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; awk &lt;span class="s1"&gt;'$1 == 1 {b++} END {print b " of " NR " files have bus factor 1"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;151 of 909 files have bus factor 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the same loop with &lt;code&gt;--since="2 years ago"&lt;/code&gt; passed through, counting only the files that had commits in that window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git ls-files &lt;span class="s1"&gt;'django/*.py'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; -r f&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"$(./busfactor.sh "&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;" --since="&lt;/span&gt;2 years ago&lt;span class="s2"&gt;") $f"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; awk &lt;span class="s1"&gt;'NF == 2 {n++; if ($1 == 1) b++} END {print b " of " n " files with commits have bus factor 1"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;136 of 460 files with commits have bus factor 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;17% over Django's whole history, 30% over the last two years. Same code, same rule, a different window. Django is also one of the most widely shared codebases in open source. Most projects look different.&lt;/p&gt;

&lt;h2&gt;
  
  
  How common is a bus factor of 1 in open source?
&lt;/h2&gt;

&lt;p&gt;I looked at production source files (tests, generated and vendored code excluded) in 195 public repositories with 1,000+ GitHub stars each, using the same 80% rule on each repo's indexed git history.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In the median repo, 60% of production files have a bus factor of 1.&lt;/li&gt;
&lt;li&gt;In 124 of the 195 repos (64%), files with a bus factor of 1 are the majority.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By primary language (GitHub's label for the repo), median share of production files with a bus factor of 1:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Language&lt;/th&gt;
&lt;th&gt;Repos&lt;/th&gt;
&lt;th&gt;Files with bus factor 1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;79%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;td&gt;70%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C#&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;66% (small group, indicative)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;td&gt;57%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;49%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JavaScript&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;45% (small group, indicative)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C++&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;44% (small group, indicative)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All 195&lt;/td&gt;
&lt;td&gt;195&lt;/td&gt;
&lt;td&gt;60%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I would not read too much into the language order. Bus factor depends heavily on how long a project has existed and how many regular contributors it has, and those vary more between repos than between languages. A three-year-old project has had far less time to spread knowledge around than Django has in 21.&lt;/p&gt;

&lt;h2&gt;
  
  
  Are the most-changed files single-owner?
&lt;/h2&gt;

&lt;p&gt;The more useful number is about hotspots, the files that change most often. If the busiest files were also single-owner, that would be a real risk: lots of change going through one person's head.&lt;/p&gt;

&lt;p&gt;The data says the opposite. In the median repo, 25% of hotspot files have a bus factor of 1, against 60% of all production files. Pooled across the 195 repos, 34% of 20,941 hotspot files are single-owner. Code that changes a lot tends to get touched by more people, which is what you would hope.&lt;/p&gt;

&lt;p&gt;So a bus factor of 1 on a file nobody edits is mostly fine. The case worth acting on is a file that is still changing often while its main author has gone quiet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you do about a bus factor of 1?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Find the overlap first: files with a bus factor of 1 that are also in the top 10% by recent churn (lines added plus deleted in the last 90 days, which &lt;code&gt;git log --numstat&lt;/code&gt; gives you). Start with that list.&lt;/li&gt;
&lt;li&gt;Route reviews on those files to a second person on purpose, even when the owner could review alone.&lt;/li&gt;
&lt;li&gt;Ask the owner for a short note on intent: why it is shaped like this, what not to change. Ten lines in a docstring or an ADR outlast a handover meeting.&lt;/li&gt;
&lt;li&gt;Do not chase a number. A bus factor of 2 on a stable utility file is not worth a reorg.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full method, including &lt;code&gt;.mailmap&lt;/code&gt; for people with several email addresses and how to run this across a whole repo, is in &lt;a href="https://repowise.dev/blog/guides/code-ownership-bus-factor-git?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=devto-04-bus-factor" rel="noopener noreferrer"&gt;this guide on measuring bus factor from git&lt;/a&gt;. For a large repo you can browse, &lt;a href="https://repowise.dev/repo/grafana/grafana/owners?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=devto-04-bus-factor" rel="noopener noreferrer"&gt;Grafana's owners page&lt;/a&gt; shows bus factor per file across its 10,000+ production files. At its last index, 24% of them had a bus factor of 1, well under the median.&lt;/p&gt;

&lt;p&gt;How does your team handle single-owner files that still change every week?&lt;/p&gt;

&lt;h2&gt;
  
  
  How I got these numbers
&lt;/h2&gt;

&lt;p&gt;The cohort is 195 of the most-starred public repositories indexed on repowise (1,000+ GitHub stars each, 253,688 production source files, November 2026), each measured at its own latest index between May and November 2026. The cohort-wide ownership numbers are &lt;a href="https://repowise.dev/research/code-health-2026?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=devto-04-bus-factor#finding-8" rel="noopener noreferrer"&gt;finding 8 of the code health 2026 report&lt;/a&gt;. Bus factor is computed per production file from each repo's indexed git history with the 80% rule above. No author names were collected or used. The Django runs are the script above on a full clone at &lt;code&gt;6a8248f&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What does the term bus factor mean?&lt;/strong&gt;&lt;br&gt;
Bus factor, also called truck factor, is the number of people who would have to leave a project before nobody left understands a piece of code. Here it is measured per file as the fewest authors who together made 80% of its commits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you calculate bus factor from git?&lt;/strong&gt;&lt;br&gt;
Run &lt;code&gt;git shortlog -sn --no-merges HEAD -- &amp;lt;file&amp;gt;&lt;/code&gt; to count commits per author, highest first, then add authors down the list until you pass 80% of the commits. The number of authors you needed is the bus factor. Add &lt;code&gt;--since&lt;/code&gt; to measure only recent history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is a bus factor of 1 bad?&lt;/strong&gt;&lt;br&gt;
Not on its own. It is the normal state of 60% of production files in the median popular repo, and a single-owner file nobody edits is mostly fine. The case to act on is a file that still changes often while its main author has gone quiet.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>programming</category>
      <category>career</category>
      <category>discuss</category>
    </item>
    <item>
      <title>TypeScript unused exports: what 52 popular TS repos carry, and how to clean yours with Knip</title>
      <dc:creator>Raghav Chamadiya</dc:creator>
      <pubDate>Fri, 13 Nov 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/repowise/typescript-unused-exports-what-52-popular-ts-repos-carry-and-how-to-clean-yours-with-knip-52j3</link>
      <guid>https://dev.to/repowise/typescript-unused-exports-what-52-popular-ts-repos-carry-and-how-to-clean-yours-with-knip-52j3</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Knip finds unused files, unused exports and unused dependencies in JavaScript and TypeScript projects by following imports from your entry points, and &lt;code&gt;npx knip&lt;/code&gt; runs it with no config.&lt;/li&gt;
&lt;li&gt;In repowise's index of 48 popular TypeScript repos (1,000+ GitHub stars), unused exports (5,759) outnumber files flagged as unreachable (2,189) more than 2 to 1.&lt;/li&gt;
&lt;li&gt;tsc's &lt;code&gt;noUnusedLocals&lt;/code&gt; does not catch unused exports, because it checks one file at a time.&lt;/li&gt;
&lt;li&gt;Clean up in this order: delete unused files, remove unused dependencies, run &lt;code&gt;knip --fix --fix-type exports&lt;/code&gt;, then delete what tsc now reports as unused.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Knip finds unused files, exports and dependencies in JavaScript and TypeScript projects. Two parts follow: what the dead code in 52 popular TypeScript repositories actually looks like, and a walk through Knip's output on one of them, repomix, including the cleanup order that does not break your build.&lt;/p&gt;

&lt;h2&gt;
  
  
  How many unused exports do popular TypeScript repos have?
&lt;/h2&gt;

&lt;p&gt;Using repowise's index of 195 public repositories with 1,000+ GitHub stars each (November 2026), I looked at production source files (tests, generated and vendored code excluded) in its 52 TypeScript repos. Dead-code findings were available for 48 of the 52.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;46 of 48 have at least one dead-code finding in production code.&lt;/li&gt;
&lt;li&gt;42 of 48 have at least one unused export: an exported symbol that nothing in the repo imports.&lt;/li&gt;
&lt;li&gt;The median repo has 10.1 findings per 100 production files, and 1.7% of its production files are flagged as unreachable (nothing imports them and they are not an entry point).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pooled across the 48 repos: 5,759 unused exports and 2,189 unreachable files, in 66,599 production files (the &lt;a href="https://repowise.dev/research/code-health-2026?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=devto-03-knip#dead-code" rel="noopener noreferrer"&gt;dead code section of the code health 2026 report&lt;/a&gt; has the numbers for all 195 repos). In TypeScript, the unused export is the common case. Whole forgotten files are the rarer one.&lt;/p&gt;

&lt;p&gt;A few recognisable repos, each measured at its own latest index:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Repo&lt;/th&gt;
&lt;th&gt;Production files&lt;/th&gt;
&lt;th&gt;Unused exports&lt;/th&gt;
&lt;th&gt;Files flagged unreachable&lt;/th&gt;
&lt;th&gt;Indexed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;grafana/grafana&lt;/td&gt;
&lt;td&gt;10,090&lt;/td&gt;
&lt;td&gt;1,050&lt;/td&gt;
&lt;td&gt;171&lt;/td&gt;
&lt;td&gt;2026-10-28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;shadcn-ui/ui&lt;/td&gt;
&lt;td&gt;3,853&lt;/td&gt;
&lt;td&gt;253&lt;/td&gt;
&lt;td&gt;69&lt;/td&gt;
&lt;td&gt;2026-11-04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;renovatebot/renovate&lt;/td&gt;
&lt;td&gt;1,471&lt;/td&gt;
&lt;td&gt;75&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2026-08-02&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;anthropics/claude-code&lt;/td&gt;
&lt;td&gt;899&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;2026-09-27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GrapesJS/grapesjs&lt;/td&gt;
&lt;td&gt;364&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2026-10-19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;effector/effector&lt;/td&gt;
&lt;td&gt;242&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;2026-10-08&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;yamadashy/repomix&lt;/td&gt;
&lt;td&gt;209&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2026-06-16&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Context decides what each finding means. I re-ran shadcn-ui/ui on a fresh clone (commit &lt;code&gt;8ab2771&lt;/code&gt;) and got 327 candidates. 251 of them sit in &lt;code&gt;apps/v4/registry/&lt;/code&gt;, the component registry. Nothing in the repo imports those components because they are not meant to be imported there: &lt;code&gt;apps/v4/registry.json&lt;/code&gt; lists each file by path (&lt;code&gt;"path": "registry/new-york-v4/ui/accordion.tsx"&lt;/code&gt;), and the shadcn CLI copies them into your project. The tool marked none of the 327 as ready to delete. A &lt;a href="https://repowise.dev/repo/shadcn-ui/ui/code-health?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=devto-03-knip" rel="noopener noreferrer"&gt;code health page like shadcn's&lt;/a&gt; is a good way to read such a list in context, and the same rule holds with any tool: a file loaded by path from a manifest is used, even with zero importers.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you use Knip?
&lt;/h2&gt;

&lt;p&gt;I ran Knip on &lt;a href="https://github.com/yamadashy/repomix" rel="noopener noreferrer"&gt;repomix&lt;/a&gt;, the last row of the table, at commit &lt;code&gt;758c3d7&lt;/code&gt; (November 8, 2026). Its dependencies were installed and there was no Knip config. It took 19 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ npx knip
Unused files (113)
browser/entrypoints/background.ts
browser/entrypoints/content.ts
...
tests/fixtures/config-ts/repomix.config.ts
...
website/server/src/index.ts
website/server/warmup.mjs
Unused dependencies (1)
@repomix/tree-sitter-wasms  package.json:84:6
Unused devDependencies (1)
@types/strip-comments  package.json:114:6
Unlisted binaries (2)
hyperfine  package.json
pinact     package.json
Unused exports (16)
outputPatternSchema                          src/config/configSchema.ts:27:14
...
validateGitRef                               src/core/git/gitCommand.ts:306:14
TOKEN_ENCODINGS                              src/core/metrics/TokenCounter.ts:8:10
getSupportedLanguages              function  src/core/treeSitter/languageConfig.ts:239:17
...
isMac                                        tests/testing/testUtils.ts:53:14
isLinux                                      tests/testing/testUtils.ts:54:14
Unused exported types (8)
...
Duplicate exports (1)
repomixConfigBaseSchema|repomixConfigFileSchema  src/config/configSchema.ts
Configuration hints (1)
. (root)    .  Create knip.json configuration file, and add entry and/or refine project files (113 unused files)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is 142 findings. Here is what they turned out to be.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unused files: 113, and none of them is dead.&lt;/strong&gt; 83 are in &lt;code&gt;website/&lt;/code&gt; (the repomix.com client and server), 7 in &lt;code&gt;browser/&lt;/code&gt; (the browser extension) and 2 in &lt;code&gt;scripts/memory/&lt;/code&gt;. Each of those folders is its own npm project with its own &lt;code&gt;package.json&lt;/code&gt;, and the root &lt;code&gt;package.json&lt;/code&gt; declares no workspaces, so to Knip they are files that nothing imports. The other 21 are test fixtures that the tests read from disk by path, for example &lt;code&gt;path.join(process.cwd(), 'tests/fixtures/config-ts')&lt;/code&gt;. Knip's last line points the same way: add a config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unused dependencies: one false, one true.&lt;/strong&gt; &lt;code&gt;@repomix/tree-sitter-wasms&lt;/code&gt; is used. &lt;code&gt;loadLanguage.ts&lt;/code&gt; resolves the grammar files by path, &lt;code&gt;`@repomix/tree-sitter-wasms/out/tree-sitter-${langName}.wasm`&lt;/code&gt;, so there is no import for Knip to follow. Same lesson as the shadcn registry. &lt;code&gt;@types/strip-comments&lt;/code&gt; really is unused: the code imports &lt;code&gt;@repomix/strip-comments&lt;/code&gt;, a fork that ships its own &lt;code&gt;index.d.ts&lt;/code&gt;, so the old types package types nothing. The two unlisted binaries, &lt;code&gt;hyperfine&lt;/code&gt; and &lt;code&gt;pinact&lt;/code&gt;, are system tools called from npm scripts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unused exports: 16, and 12 of them are used.&lt;/strong&gt; &lt;code&gt;validateGitRef&lt;/code&gt; is exported from &lt;code&gt;gitCommand.ts&lt;/code&gt; and called on line 192 of the same file. Knip asks whether another file imports an export, and for 12 of the 16 the answer is no, but the file itself uses them. Only the &lt;code&gt;export&lt;/code&gt; keyword is unused. With &lt;code&gt;"ignoreExportsUsedInFile": true&lt;/code&gt; in the config, the list drops to 4:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;getSupportedLanguages&lt;/code&gt; in &lt;code&gt;languageConfig.ts&lt;/code&gt; has no caller anywhere in the repo.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TOKEN_ENCODINGS&lt;/code&gt; in &lt;code&gt;TokenCounter.ts&lt;/code&gt; is a re-export kept "for backward compatibility" when the constant moved to &lt;code&gt;tokenEncodings.ts&lt;/code&gt; to speed up startup. Every caller now imports it from the new file, so the compatibility line has outlived its callers.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;isMac&lt;/code&gt; and &lt;code&gt;isLinux&lt;/code&gt; are test helpers in &lt;code&gt;tests/testing/testUtils.ts&lt;/code&gt; that no test uses.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The table's 0 for repomix is repowise's June index. This is Knip on a November commit, and Knip counts an export used only inside its own file as unused, which is 12 of these 16.&lt;/p&gt;

&lt;p&gt;Knip exits with code 1 when it finds something, so &lt;code&gt;npx knip&lt;/code&gt; works as a CI check as-is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why doesn't tsc catch unused exports?
&lt;/h2&gt;

&lt;p&gt;repomix with &lt;code&gt;noUnusedLocals&lt;/code&gt; turned on, before any cleanup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ npx tsc --noEmit --noUnusedLocals
tests/core/output/diffsInOutput.test.ts(109,11): error TS6133: '_output' is declared but its value is never read.
tests/core/output/diffsInOutput.test.ts(183,11): error TS6133: '_output' is declared but its value is never read.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two unused variables in a test, and none of the 24 exports and types Knip listed. &lt;code&gt;noUnusedLocals&lt;/code&gt; and &lt;code&gt;@typescript-eslint/no-unused-vars&lt;/code&gt; look inside one file at a time. An exported symbol is part of the file's interface, so from the file's point of view it is always used. Finding exports that nobody imports needs a tool that reads the whole project's import graph, which is what Knip does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clean up in the right order
&lt;/h2&gt;

&lt;p&gt;Knip can fix exports for you. It removes the &lt;code&gt;export&lt;/code&gt; keyword. On repomix, with the config from the next section so it leaves &lt;code&gt;website/&lt;/code&gt; alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ npx knip --fix --fix-type exports,types
...
Unused exports (16)
validateGitRef                               src/core/git/gitCommand.ts:306:14              (removed)
TOKEN_ENCODINGS                              src/core/metrics/TokenCounter.ts:8:10          (removed)
LANGUAGE_CONFIGS                             src/core/treeSitter/languageConfig.ts:64:14    (removed)
getSupportedLanguages              function  src/core/treeSitter/languageConfig.ts:239:17   (removed)
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;24 lines changed in 17 files. Two of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-export { TOKEN_ENCODINGS, type TokenEncoding };&lt;/span&gt;
&lt;span class="gi"&gt;+export {  type TokenEncoding };&lt;/span&gt;
&lt;span class="gd"&gt;-export const LANGUAGE_CONFIGS: readonly LanguageConfig[] = [&lt;/span&gt;
&lt;span class="gi"&gt;+const LANGUAGE_CONFIGS: readonly LanguageConfig[] = [&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The build still passes. Now run the compiler with &lt;code&gt;noUnusedLocals&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ npx tsc --noEmit
$ npx tsc --noEmit --noUnusedLocals
src/config/configSchema.ts(38,6): error TS6196: 'FileProcessorOnError' is declared but never used.
src/config/configSchema.ts(208,6): error TS6196: 'RepomixConfigDefault' is declared but never used.
src/core/metrics/TokenCounter.ts(4,10): error TS6133: 'TOKEN_ENCODINGS' is declared but its value is never read.
src/core/treeSitter/languageConfig.ts(239,10): error TS6133: 'getSupportedLanguages' is declared but its value is never read.
tests/core/output/diffsInOutput.test.ts(109,11): error TS6133: '_output' is declared but its value is never read.
tests/core/output/diffsInOutput.test.ts(183,11): error TS6133: '_output' is declared but its value is never read.
tests/testing/testUtils.ts(53,7): error TS6133: 'isMac' is declared but its value is never read.
tests/testing/testUtils.ts(54,7): error TS6133: 'isLinux' is declared but its value is never read.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six new lines: the four exports and two of the three types that nothing uses at all. The third type, &lt;code&gt;SecurityCheckType&lt;/code&gt;, was a pass-through re-export, so removing it left nothing behind to flag. The compiler stays quiet about the other 17, because their own files use them. Knip and the compiler work as a pair: Knip finds what is unused across files, and the compiler finds what is unused inside one.&lt;/p&gt;

&lt;p&gt;repomix has no dead files, so this run cannot show the other trap. Knip does not count imports that come from unused files. If a dead file is the only importer of a helper, Knip reports the helper's export as unused, &lt;code&gt;--fix&lt;/code&gt; removes the &lt;code&gt;export&lt;/code&gt;, and the dead file stops compiling. Delete unused files first.&lt;/p&gt;

&lt;p&gt;So the order I use:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Delete unused files (&lt;code&gt;--allow-remove-files&lt;/code&gt; lets &lt;code&gt;--fix&lt;/code&gt; do it, but I prefer to read each one first).&lt;/li&gt;
&lt;li&gt;Remove unused dependencies.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;knip --fix --fix-type exports&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;tsc&lt;/code&gt; and delete whatever it now reports as unused.&lt;/li&gt;
&lt;li&gt;One pull request per step, so each review is boring.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How do you configure Knip for a real project?
&lt;/h2&gt;

&lt;p&gt;On an application, Knip's plugins detect most frameworks and add their entry points for you (Next.js pages and routes, Vite, Vitest, Storybook and many more). What is left is usually scripts, fixtures, generated code and folders that are their own project. The config I used on repomix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;"ignore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"website/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"browser/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"scripts/memory/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"tests/**/fixtures/**"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"ignoreBinaries"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"hyperfine"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"pinact"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lines take the run from 142 findings to 27. Ignoring &lt;code&gt;website/&lt;/code&gt; and &lt;code&gt;browser/&lt;/code&gt; is the quick version. The thorough one is to run Knip inside each of those projects too, since each has its own entry points. &lt;code&gt;"ignoreDependencies": ["@repomix/tree-sitter-wasms"]&lt;/code&gt; would clear the last false positive.&lt;/p&gt;

&lt;p&gt;Start with no config, read the report, and add one line per false positive. If a whole category is wrong, it is almost always a missing entry point or a folder that is really a separate project.&lt;/p&gt;

&lt;p&gt;On a library, the public API is whatever &lt;code&gt;package.json&lt;/code&gt; exposes through &lt;code&gt;main&lt;/code&gt;, &lt;code&gt;exports&lt;/code&gt; or &lt;code&gt;bin&lt;/code&gt;. By default Knip does not report unused exports from those entry files, because your users import them. &lt;code&gt;--include-entry-exports&lt;/code&gt; turns that on when you want to audit the public surface itself. That one default is the biggest reason different unused-export tools disagree on the same repo, which &lt;a href="https://repowise.dev/blog/comparisons/knip-vs-ts-prune-vs-unimported?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=devto-03-knip" rel="noopener noreferrer"&gt;this comparison of Knip, ts-prune and unimported&lt;/a&gt; measured on one real monorepo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Barrel files
&lt;/h2&gt;

&lt;p&gt;Knip follows &lt;code&gt;export *&lt;/code&gt; through barrel files, so a barrel does not hide an unused export. Barrels still cost you elsewhere: every file that imports &lt;code&gt;./utils&lt;/code&gt; depends on everything the barrel re-exports, which slows down bundlers and test runners and creates import cycles between files that never use each other. Barrels and cycles in these same 52 repos are the subject of a later post.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I got these numbers
&lt;/h2&gt;

&lt;p&gt;The cohort is 195 of the most-starred public repositories indexed on repowise (1,000+ GitHub stars each, 253,688 production source files, November 2026). Dead-code findings are repowise's engine findings at confidence 0.4 and above, counted on production files only, with each repo measured at its own latest index between May and November 2026. The shadcn-ui/ui check was &lt;code&gt;repowise init --yes --no-prose --no-editor-setup&lt;/code&gt; (repowise 0.60.1) on a clone at &lt;code&gt;8ab2771&lt;/code&gt;. The Knip runs are Knip 6.44.2 on Node 22.19, on a clone of yamadashy/repomix at &lt;code&gt;758c3d7&lt;/code&gt; with its own dependencies and its own TypeScript (7.0.2) for the tsc runs. I reverted the &lt;code&gt;--fix&lt;/code&gt; changes afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What does Knip do?&lt;/strong&gt;&lt;br&gt;
Knip finds the entry points of a JavaScript or TypeScript project (from &lt;code&gt;package.json&lt;/code&gt;, your config and its framework plugins), follows every import from there, and reports files, exports and dependencies that nothing reaches. It sees through barrel files that use &lt;code&gt;export *&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Knip work on plain JavaScript?&lt;/strong&gt;&lt;br&gt;
Yes. Knip handles JavaScript and TypeScript projects, and the same &lt;code&gt;npx knip&lt;/code&gt; command works for both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can Knip run in CI?&lt;/strong&gt;&lt;br&gt;
Yes. &lt;code&gt;npx knip&lt;/code&gt; exits with code 1 when it finds something, so it fails a CI step as-is. Add a &lt;code&gt;knip.json&lt;/code&gt; for scripts and generated code first, or the first runs will fail on false positives.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Circular imports in Python: why every big Python repo has them, and the few that actually break</title>
      <dc:creator>Raghav Chamadiya</dc:creator>
      <pubDate>Thu, 12 Nov 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/repowise/circular-imports-in-python-why-every-big-python-repo-has-them-and-the-few-that-actually-break-4d86</link>
      <guid>https://dev.to/repowise/circular-imports-in-python-why-every-big-python-repo-has-them-and-the-few-that-actually-break-4d86</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All 51 popular Python repos I checked (1,000+ GitHub stars each) have circular import chains between files; in the median one, 16.4% of production files sit inside one.&lt;/li&gt;
&lt;li&gt;A Python circular import only fails with "partially initialized module" when a name is read from a module that has not finished running, such as a module-level &lt;code&gt;from x import y&lt;/code&gt;, a base class, or a decorator that runs on import.&lt;/li&gt;
&lt;li&gt;Django has a 179-file import cycle and imports fine, because most of its cycles go through imports inside functions or imports used only for type hints.&lt;/li&gt;
&lt;li&gt;Fixes, in order: move the shared code to a third module, import the module instead of the name (with &lt;code&gt;from __future__ import annotations&lt;/code&gt;), put type-only imports under &lt;code&gt;TYPE_CHECKING&lt;/code&gt;, or import inside the function.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the error that sends people searching:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ImportError: cannot import name 'User' from partially initialized module 'shop.models' (most likely due to a circular import)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The usual advice is "you have a circular import, remove it". But I used repowise to look at the import graphs of 51 popular Python repositories (1,000+ GitHub stars each), and every single one has circular import chains between its files. In the median repo, 16.4% of production files sit inside one. In Django, one chain links 179 files, and Django imports fine.&lt;/p&gt;

&lt;p&gt;So a cycle on its own is not the problem. Below: which cycles actually raise that error, why most do not, and how to fix the ones that do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;Two modules in a package, each importing a name from the other:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# shop/models.py&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;services&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;send_welcome&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# shop/services.py&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_welcome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"welcome, {user.email}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# main.py&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;services&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;send_welcome&lt;/span&gt;

&lt;span class="nf"&gt;send_welcome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"a@example.com"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python main.py
Traceback (most recent call last):
  File "D:\devto-scratch\repro\shop\main.py", line 1, in &amp;lt;module&amp;gt;
    from shop.models import User
  File "D:\devto-scratch\repro\shop\shop\models.py", line 1, in &amp;lt;module&amp;gt;
    from shop.services import send_welcome
  File "D:\devto-scratch\repro\shop\shop\services.py", line 1, in &amp;lt;module&amp;gt;
    from shop.models import User
ImportError: cannot import name 'User' from partially initialized module 'shop.models' (most likely due to a circular import) (D:\devto-scratch\repro\shop\shop\models.py)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What happens, step by step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;main.py&lt;/code&gt; imports &lt;code&gt;shop.models&lt;/code&gt;. Python creates an empty module object, puts it in &lt;code&gt;sys.modules&lt;/code&gt;, and starts running &lt;code&gt;models.py&lt;/code&gt; from the top.&lt;/li&gt;
&lt;li&gt;Line 1 of &lt;code&gt;models.py&lt;/code&gt; imports &lt;code&gt;shop.services&lt;/code&gt;, so Python pauses &lt;code&gt;models.py&lt;/code&gt; and starts running &lt;code&gt;services.py&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Line 1 of &lt;code&gt;services.py&lt;/code&gt; asks for &lt;code&gt;User&lt;/code&gt; from &lt;code&gt;shop.models&lt;/code&gt;. The module exists in &lt;code&gt;sys.modules&lt;/code&gt;, but it is still on line 1. &lt;code&gt;User&lt;/code&gt; has not been defined yet.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;"Partially initialized" means exactly that: the module object exists, its code has not finished running. The cycle is only fatal because a name is read from the half-built module while it is still half built.&lt;/p&gt;

&lt;h2&gt;
  
  
  How common are circular imports in Python projects?
&lt;/h2&gt;

&lt;p&gt;The 51 Python repos in the set include django, fastapi, airflow, langchain, prefect and paperless-ngx. Looking at file-to-file imports in production code only (tests, generated and vendored code excluded):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;51 of 51 have at least one circular import group (a set of 2+ files that can all reach each other through imports).&lt;/li&gt;
&lt;li&gt;In the median Python repo, 16.4% of production files sit inside one.&lt;/li&gt;
&lt;li&gt;Across all 195 repos in the wider set, covering every language, the median is 9.2%.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To see why they do not break, I took a fresh clone of Django (commit &lt;code&gt;6a8248f&lt;/code&gt;) and looked at its import graph. Counting every import between the 900 non-test &lt;code&gt;.py&lt;/code&gt; files under &lt;code&gt;django/&lt;/code&gt;, 238 files sit inside a circular group and the largest group holds 179 files (the whole-repo index counts more, because it also includes files outside &lt;code&gt;django/&lt;/code&gt;), from &lt;code&gt;django/__init__.py&lt;/code&gt; through &lt;code&gt;apps&lt;/code&gt;, &lt;code&gt;conf&lt;/code&gt;, &lt;code&gt;db&lt;/code&gt; and parts of &lt;code&gt;contrib&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then I dropped two kinds of import edges: imports written inside a function, and imports used only for type hints. Those two kinds do not run when the module is first loaded. The result:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Django, files under &lt;code&gt;django/&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Circular groups&lt;/th&gt;
&lt;th&gt;Files inside a group&lt;/th&gt;
&lt;th&gt;Largest group&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;All imports&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;238&lt;/td&gt;
&lt;td&gt;179 files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Only imports that run at module load&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;td&gt;35 files&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Removing 152 import edges, about 5% of the total, shrinks the files involved by three quarters. Most of Django's cycles exist only because some function, somewhere, imports something when it is called. By then every module has finished loading, so there is nothing half built to trip over. Django's code is explicit about it. &lt;code&gt;django/db/models/query_utils.py&lt;/code&gt; has this inside &lt;code&gt;Q.check()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;        &lt;span class="c1"&gt;# Avoid circular imports.&lt;/span&gt;
        &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;Value&lt;/span&gt;
        &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;Query&lt;/span&gt;
        &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;constants&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="no"&gt;SINGLE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;git grep -i "circular import" -- 'django/*.py'&lt;/code&gt; finds 16 comments like that in 11 files.&lt;/p&gt;

&lt;p&gt;The 35-file group that remains is &lt;code&gt;django/db/models&lt;/code&gt;, and it survives import because of ordering. &lt;code&gt;django/db/models/__init__.py&lt;/code&gt; re-exports names from its submodules, and the order of those imports is pinned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Imports that would create circular imports if sorted&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="no"&gt;DEFERRED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;  &lt;span class="c1"&gt;# isort:skip&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;related&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;  &lt;span class="c1"&gt;# isort:skip&lt;/span&gt;
    &lt;span class="nc"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;ForeignObject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;OneToOneField&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the real picture of a big Python codebase is: lots of cycles, nearly all of them harmless, and a handful held together by import order. The error shows up when someone reorders those imports or adds a new module-level import in the wrong direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which circular imports raise "partially initialized module"?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. A module-level &lt;code&gt;from x import y&lt;/code&gt; that runs while &lt;code&gt;x&lt;/code&gt; is half built
&lt;/h3&gt;

&lt;p&gt;That is the reproduction above. &lt;code&gt;from module import name&lt;/code&gt; reads the name immediately. If &lt;code&gt;module&lt;/code&gt; is still running higher up the stack, the name does not exist yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Inheriting from a class across the cycle
&lt;/h3&gt;

&lt;p&gt;A class statement needs its base class at the moment the class is created, so even "import the module, not the name" does not save you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# plugins/base.py&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;EmailNotifier&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Notifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;NotImplementedError&lt;/span&gt;


&lt;span class="no"&gt;DEFAULT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;EmailNotifier&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# plugins/email.py&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmailNotifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Notifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"email:"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  File "D:\devto-scratch\repro\inherit\plugins\email.py", line 4, in &amp;lt;module&amp;gt;
    class EmailNotifier(base.Notifier):
                        ^^^^^^^^^^^^^
AttributeError: partially initialized module 'plugins.base' from 'D:\devto-scratch\repro\inherit\plugins\base.py' has no attribute 'Notifier' (most likely due to a circular import)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Registries and decorators that run on import
&lt;/h3&gt;

&lt;p&gt;The classic web-app layout: &lt;code&gt;app.py&lt;/code&gt; imports the views so the routes get registered, and &lt;code&gt;views.py&lt;/code&gt; imports &lt;code&gt;app&lt;/code&gt; to use its decorator.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# web/app.py&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;web&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;views&lt;/span&gt;  &lt;span class="c1"&gt;# load the routes&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;App&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;routes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt;
            &lt;span class="nf"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt;
        &lt;span class="nf"&gt;return&lt;/span&gt; &lt;span class="n"&gt;register&lt;/span&gt;


&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;App&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# web/views.py&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;web&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;


&lt;span class="nd"&gt;@app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"hello"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  File "D:\devto-scratch\repro\registry\web\views.py", line 1, in &amp;lt;module&amp;gt;
    from web.app import app
ImportError: cannot import name 'app' from partially initialized module 'web.app' (most likely due to a circular import) (D:\devto-scratch\repro\registry\web\app.py)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decorator runs at import time, so it needs &lt;code&gt;app&lt;/code&gt; before &lt;code&gt;app.py&lt;/code&gt; has created it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you fix a circular import in Python?
&lt;/h2&gt;

&lt;p&gt;All of these were run on Python 3.13.&lt;/p&gt;

&lt;h3&gt;
  
  
  Move the shared piece to a third module
&lt;/h3&gt;

&lt;p&gt;The best fix removes the cycle. In the inheritance example, &lt;code&gt;base.py&lt;/code&gt; only imported &lt;code&gt;EmailNotifier&lt;/code&gt; to set a default. Move the default somewhere that sits above both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# plugins/base.py&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Notifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;NotImplementedError&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# plugins/defaults.py&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;EmailNotifier&lt;/span&gt;

&lt;span class="no"&gt;DEFAULT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;EmailNotifier&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python main.py
email: hi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;base&lt;/code&gt; imports nothing, &lt;code&gt;email&lt;/code&gt; imports &lt;code&gt;base&lt;/code&gt;, &lt;code&gt;defaults&lt;/code&gt; imports &lt;code&gt;email&lt;/code&gt;. The arrows only point one way. For the registry example the same idea is an app factory, or a &lt;code&gt;main.py&lt;/code&gt; that imports &lt;code&gt;app&lt;/code&gt; first and &lt;code&gt;views&lt;/code&gt; second.&lt;/p&gt;

&lt;h3&gt;
  
  
  Import the module, not the name, and defer annotations
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;from shop import models&lt;/code&gt; binds the module object, which exists even while it is half built. You then read &lt;code&gt;models.User&lt;/code&gt; later, when it exists. One catch: annotations are evaluated when the function is defined, so this still fails on Python 3.13:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# shop/services.py&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;shop&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_welcome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"welcome, {user.email}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    def send_welcome(user: models.User):
                           ^^^^^^^^^^^
AttributeError: partially initialized module 'shop.models' from 'D:\devto-scratch\repro\shop\shop\models.py' has no attribute 'User' (most likely due to a circular import)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;from __future__ import annotations&lt;/code&gt; at the top of &lt;code&gt;services.py&lt;/code&gt; and it runs, because annotations become strings that are never evaluated at import time. (Python 3.14 defers annotation evaluation by default, PEP 649.)&lt;/p&gt;

&lt;h3&gt;
  
  
  If the import is only for type hints, use &lt;code&gt;TYPE_CHECKING&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# shop/services.py&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;__future__&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;annotations&lt;/span&gt;

&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="no"&gt;TYPE_CHECKING&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;TYPE_CHECKING&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_welcome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"welcome, {user.email}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python main.py
welcome, a@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The import only exists for mypy, pyright and your editor. At runtime it never executes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Import inside the function
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# shop/models.py&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;welcome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;services&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;send_welcome&lt;/span&gt;
        &lt;span class="nf"&gt;send_welcome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is what Django does in those 16 commented places. It works, and it is fine as a local patch. It is also how a codebase ends up with a 179-file cycle that nobody can see, so leave a comment, like Django does, saying why the import is there.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you find circular imports in your own project?
&lt;/h2&gt;

&lt;p&gt;For a list in the terminal, pylint has a check for exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pylint --disable=all --enable=cyclic-import django/db
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Django's &lt;code&gt;django/db&lt;/code&gt; it reported 72 cyclic-import messages in 8.5 seconds, for example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;django\db\models\sql\__init__.py:1:0: R0401: Cyclic import (django.db.models.manager -&amp;gt; django.db.models.query) (cyclic-import)
django\db\models\sql\__init__.py:1:0: R0401: Cyclic import (django.db.models.base -&amp;gt; django.db.models.fields.related) (cyclic-import)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;pylint counts imports inside functions too, so expect it to list cycles that never fail at runtime. &lt;code&gt;pydeps yourpackage --show-cycles&lt;/code&gt; draws the same cycles as a graph (it needs Graphviz installed). To stop new cycles in CI, import-linter can fail the build on cycles between subpackages. The setup for that, plus the TypeScript and Go equivalents, is in &lt;a href="https://repowise.dev/blog/guides/find-circular-dependencies-monorepo?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=devto-02-python-circular-imports" rel="noopener noreferrer"&gt;this guide to finding circular dependencies&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;To look at the cycles on a picture instead of a list, &lt;a href="https://repowise.dev/repo/django/django/architecture?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=devto-02-python-circular-imports" rel="noopener noreferrer"&gt;Django's architecture view&lt;/a&gt; shows the same import graph, and the trace tool on that page finds the path between any two files.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I got these numbers
&lt;/h2&gt;

&lt;p&gt;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), each measured at its own latest index between May and November 2026. The cross-language numbers are in &lt;a href="https://repowise.dev/research/code-health-2026?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=devto-02-python-circular-imports#finding-6" rel="noopener noreferrer"&gt;finding 6 of the code health 2026 report&lt;/a&gt;. Cycles are counted on file-to-file imports between production files, so &lt;code&gt;__init__.py&lt;/code&gt; re-exports count as edges. The Django breakdown comes from &lt;code&gt;repowise init --yes --no-prose --no-editor-setup&lt;/code&gt; on a fresh clone at &lt;code&gt;6a8248f&lt;/code&gt;, then a strongly connected components pass over the stored import graph, once with every import edge and once without the edges marked as function-level or type-only.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What does "partially initialized module ... most likely due to a circular import" mean?&lt;/strong&gt;&lt;br&gt;
Python has created the module object and put it in &lt;code&gt;sys.modules&lt;/code&gt;, but the module's code is still running further up the import chain. Something asked for a name that the module has not defined yet, so the import fails with that message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does &lt;code&gt;from __future__ import annotations&lt;/code&gt; fix circular imports?&lt;/strong&gt;&lt;br&gt;
Only when the cycle breaks on a type hint. It turns annotations into strings that are not evaluated at import time, so &lt;code&gt;def f(user: models.User)&lt;/code&gt; stops failing. It does not help when the name is needed while the module loads, such as a base class or a decorator.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Are circular imports always a bug in Python?&lt;/strong&gt;&lt;br&gt;
No. All 51 Python repos in this set have circular import chains between files and import fine. A cycle only fails when one module reads a name from another before that module has finished running.&lt;/p&gt;

</description>
      <category>python</category>
      <category>programming</category>
      <category>tutorial</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Python dead code: what 51 popular Python repos ship that nothing imports</title>
      <dc:creator>Raghav Chamadiya</dc:creator>
      <pubDate>Wed, 11 Nov 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/repowise/python-dead-code-what-51-popular-python-repos-ship-that-nothing-imports-3eg3</link>
      <guid>https://dev.to/repowise/python-dead-code-what-51-popular-python-repos-ship-that-nothing-imports-3eg3</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;814 of the 819 files flagged in LangChain are compatibility shims that keep old import paths working for its users.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What counts as dead code in Python?
&lt;/h2&gt;

&lt;p&gt;Two kinds of finding, on production source files only (tests, generated code, vendored code, build scripts and docs examples are excluded):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A file flagged as &lt;strong&gt;unreachable&lt;/strong&gt;: no other file imports it, and it is not an entry point.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;unused export&lt;/strong&gt;: a public function, class or constant defined in one module that no other module imports.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each finding has a confidence between 0 and 1. I call 0.7 and above "high confidence".&lt;/p&gt;

&lt;h2&gt;
  
  
  How much dead code do popular Python projects carry?
&lt;/h2&gt;

&lt;p&gt;Dead-code data exists for 50 of the 51 Python repos.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;The median repo has 15.7 findings per 100 production files, and 3.9% of its production files are flagged as unreachable.&lt;/li&gt;
&lt;li&gt;Pooled across the 50 repos: 4,074 flagged files and 6,008 unused exports, in 38,285 production files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some recognisable names, each measured at its own latest index:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Repo&lt;/th&gt;
&lt;th&gt;Production files&lt;/th&gt;
&lt;th&gt;Files flagged unreachable&lt;/th&gt;
&lt;th&gt;Unused exports&lt;/th&gt;
&lt;th&gt;Indexed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fastapi/fastapi&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2026-11-04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tobymao/sqlglot&lt;/td&gt;
&lt;td&gt;185&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2026-10-26&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;litestar-org/litestar&lt;/td&gt;
&lt;td&gt;325&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2026-11-10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;paperless-ngx/paperless-ngx&lt;/td&gt;
&lt;td&gt;443&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;2026-10-15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;django/django&lt;/td&gt;
&lt;td&gt;870&lt;/td&gt;
&lt;td&gt;154&lt;/td&gt;
&lt;td&gt;73&lt;/td&gt;
&lt;td&gt;2026-07-29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PrefectHQ/prefect&lt;/td&gt;
&lt;td&gt;2,281&lt;/td&gt;
&lt;td&gt;153&lt;/td&gt;
&lt;td&gt;166&lt;/td&gt;
&lt;td&gt;2026-07-17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;apache/airflow&lt;/td&gt;
&lt;td&gt;4,387&lt;/td&gt;
&lt;td&gt;424&lt;/td&gt;
&lt;td&gt;556&lt;/td&gt;
&lt;td&gt;2026-08-19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;langchain-ai/langchain&lt;/td&gt;
&lt;td&gt;1,714&lt;/td&gt;
&lt;td&gt;819&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2026-11-04&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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 &lt;code&gt;docs_src/&lt;/code&gt; (the code samples behind its documentation), &lt;code&gt;scripts/&lt;/code&gt; and &lt;code&gt;docs/&lt;/code&gt;, none in &lt;code&gt;fastapi/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I found when I checked by hand
&lt;/h2&gt;

&lt;p&gt;I cloned each repo, indexed it with the repowise CLI, listed the findings, and then read the flagged files in groups.&lt;/p&gt;

&lt;h3&gt;
  
  
  Django: 82 of 100 candidates are locale files loaded by string
&lt;/h3&gt;

&lt;p&gt;On Django at commit &lt;code&gt;6a8248f&lt;/code&gt;, the dead-code pass returned 100 candidates. 82 of them are files like these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;django/conf/locale/ar/formats.py
django/conf/locale/ar_DZ/formats.py
django/conf/locale/az/formats.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No Python file imports &lt;code&gt;django/conf/locale/ar/formats.py&lt;/code&gt;. Django loads it at runtime, by building the module name as a string, in &lt;code&gt;django/utils/formats.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;location&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;format_locations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;loc&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;locales&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nf"&gt;import_module&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"%s.formats"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nc"&gt;ImportError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;docs/&lt;/code&gt;, and three functions in &lt;code&gt;scripts/manage_translations.py&lt;/code&gt; that its command-line parser dispatches to.&lt;/p&gt;

&lt;p&gt;These are files to keep, and the tool treats them that way. Asking for deletion-ready findings only returns nothing, with the reason:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ 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.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  LangChain: 814 files that keep old import paths working
&lt;/h3&gt;

&lt;p&gt;On LangChain at commit &lt;code&gt;1a41b51&lt;/code&gt;, 819 files are flagged as unreachable, and 814 of them sit in one package: &lt;code&gt;libs/langchain/langchain_classic&lt;/code&gt;. Nearly all look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="no"&gt;TYPE_CHECKING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Any&lt;/span&gt;

&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_classic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_api&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;create_importer&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;TYPE_CHECKING&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_community&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agent_toolkits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jira&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;toolkit&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;JiraToolkit&lt;/span&gt;

&lt;span class="c1"&gt;# Create a way to dynamically look up deprecated imports.&lt;/span&gt;
&lt;span class="c1"&gt;# Used to consolidate logic for raising deprecation warnings and&lt;/span&gt;
&lt;span class="c1"&gt;# handling optional imports.&lt;/span&gt;
&lt;span class="no"&gt;DEPRECATED_LOOKUP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"JiraToolkit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"langchain_community.agent_toolkits.jira.toolkit"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;_import_attribute&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_importer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__package__&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deprecated_lookups&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="no"&gt;DEPRECATED_LOOKUP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__getattr__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="s2"&gt;"""Look up attributes dynamically."""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_import_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a compatibility shim. Code written against an older LangChain still does &lt;code&gt;from langchain.agents.agent_toolkits.jira.toolkit import JiraToolkit&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;I sorted all 819 by what they contain: 758 use &lt;code&gt;create_importer&lt;/code&gt; 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 &lt;code&gt;version.py&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Across the whole repo, one finding is marked ready to delete: an example function, &lt;code&gt;run_calculator_http&lt;/code&gt;, that &lt;code&gt;git grep&lt;/code&gt; finds nowhere except its own definition.&lt;/p&gt;

&lt;h3&gt;
  
  
  Airflow: example DAGs, docs configs, and one repeated helper
&lt;/h3&gt;

&lt;p&gt;On Airflow at commit &lt;code&gt;6a8bc716&lt;/code&gt;, the pass returned 791 candidates: 352 flagged files, 436 unused exports and 3 unused workspace packages. I sorted the 352 files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;111 are example DAGs under &lt;code&gt;example_dags/&lt;/code&gt; folders. Airflow never imports them. It points its DAG processor at the folder and parses every file in it, in &lt;code&gt;airflow-core/src/airflow/dag_processing/bundles/manager.py&lt;/code&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_add_example_dag_bundle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bundle_config_list&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_ExternalBundleConfig&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;airflow&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;example_dags&lt;/span&gt;

    &lt;span class="n"&gt;example_dag_folder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;example_dags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;__path__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;109 are &lt;code&gt;docs/conf.py&lt;/code&gt; files, one Sphinx config per provider package. Sphinx loads them by path.&lt;/li&gt;
&lt;li&gt;14 are generated OpenAPI client files for the web UI, and most of the rest are other front-end and dev tooling files.&lt;/li&gt;
&lt;li&gt;59 are copies of a small &lt;code&gt;version_compat.py&lt;/code&gt; helper from the provider package template. In those 59 providers, &lt;code&gt;git grep&lt;/code&gt; finds 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The pattern
&lt;/h3&gt;

&lt;p&gt;In all three, most of the files an import graph cannot reach were reached another way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;by a module name built from a string (&lt;code&gt;import_module&lt;/code&gt;, &lt;code&gt;__import__&lt;/code&gt;, Django's &lt;code&gt;INSTALLED_APPS&lt;/code&gt;, Celery task names), or a folder scanned at runtime (Airflow's DAG folders),&lt;/li&gt;
&lt;li&gt;by packaging metadata (console scripts and plugin entry points in &lt;code&gt;pyproject.toml&lt;/code&gt;),&lt;/li&gt;
&lt;li&gt;or by code outside the repo (a published library's public import paths).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vulture or an import graph: what does each one find?
&lt;/h2&gt;

&lt;p&gt;The tool most Python developers know is &lt;a href="https://github.com/jendrikseipp/vulture" rel="noopener noreferrer"&gt;vulture&lt;/a&gt;. It reads each file's syntax tree and reports names that are defined and never used. On the same Django clone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ 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)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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. &lt;code&gt;process_view(self, request, view_func, view_args, view_kwargs)&lt;/code&gt; has to accept &lt;code&gt;view_args&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The two checks answer different questions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;vulture&lt;/th&gt;
&lt;th&gt;Import-graph pass&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Unit&lt;/td&gt;
&lt;td&gt;a name inside a file&lt;/td&gt;
&lt;td&gt;a whole file, or a name other modules should import&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Good at&lt;/td&gt;
&lt;td&gt;dead branches, leftover variables, unused private helpers&lt;/td&gt;
&lt;td&gt;modules nothing reaches, exports nobody imports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blind spot&lt;/td&gt;
&lt;td&gt;names used by frameworks through reflection&lt;/td&gt;
&lt;td&gt;files loaded by string or from outside the repo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Use both. They rarely flag the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you find dead code in a Python project?
&lt;/h2&gt;

&lt;p&gt;Start with vulture at a high threshold, and keep a whitelist for the framework hooks it cannot see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip install vulture
vulture src/ --min-confidence 80
vulture src/ --make-whitelist &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; whitelist.py   &lt;span class="c"&gt;# review it, then:&lt;/span&gt;
vulture src/ whitelist.py --min-confidence 80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then look at whole files and exports. With the repowise CLI, which builds the import graph locally and needs no API key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip install repowise
repowise init --yes --no-prose --no-editor-setup
repowise dead-code --min-confidence 0.7
repowise dead-code --safe-only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For every file on that list, before deleting it, search for its dotted module name and its file stem as strings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If anything builds the name dynamically, you will usually find the prefix, as in the first line here. Search &lt;code&gt;pyproject.toml&lt;/code&gt; and &lt;code&gt;setup.cfg&lt;/code&gt; too, for entry points.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would delete first
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Files that are flagged, have no string reference anywhere, and have not changed in a long time. Old and unreferenced is the safest combination.&lt;/li&gt;
&lt;li&gt;Unused private helpers vulture finds at 80% or more inside files you already own. Low risk, small diffs.&lt;/li&gt;
&lt;li&gt;Nothing in a published package's import surface until a major version. Move it behind a deprecation warning first, the way LangChain does.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python -c &lt;span class="s2"&gt;"import importlib, pkgutil, mypkg; [importlib.import_module(m.name) for m in pkgutil.walk_packages(mypkg.__path__, 'mypkg.')]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you are choosing a tool, &lt;a href="https://repowise.dev/blog/comparisons/best-dead-code-detection-tools?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=devto-01-python-dead-code" rel="noopener noreferrer"&gt;this comparison of dead code detection tools&lt;/a&gt; covers vulture, the import-graph approach and the options for other languages. Django's &lt;a href="https://repowise.dev/repo/django/django/code-health?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=devto-01-python-dead-code" rel="noopener noreferrer"&gt;code health page&lt;/a&gt; shows the dead-code list from the table above in the browser.&lt;/p&gt;

&lt;p&gt;Which loading pattern has bitten you when deleting "unused" Python code: string imports, entry points, or something else?&lt;/p&gt;

&lt;h2&gt;
  
  
  How I got these numbers
&lt;/h2&gt;

&lt;p&gt;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 &lt;a href="https://repowise.dev/research/code-health-2026?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=devto-01-python-dead-code#dead-code" rel="noopener noreferrer"&gt;code health 2026 report&lt;/a&gt;. 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 &lt;code&gt;6a8248f&lt;/code&gt; (full history), LangChain &lt;code&gt;1a41b51&lt;/code&gt; and Airflow &lt;code&gt;6a8bc716&lt;/code&gt; (last 300 commits), FastAPI &lt;code&gt;ba5d72b&lt;/code&gt; (last 500 commits), each indexed with &lt;code&gt;repowise init --yes --no-prose --no-editor-setup&lt;/code&gt; (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.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What does vulture do in Python?&lt;/strong&gt;&lt;br&gt;
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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Ruff find dead code?&lt;/strong&gt;&lt;br&gt;
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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I find unreachable code in Python?&lt;/strong&gt;&lt;br&gt;
For statements that can never run, such as code after a &lt;code&gt;return&lt;/code&gt;, pylint reports &lt;code&gt;unreachable&lt;/code&gt; (W0101). For whole files that nothing reaches, build the import graph (for example &lt;code&gt;repowise dead-code --min-confidence 0.7&lt;/code&gt;), then search for each file's dotted module name as a string before deleting it, because Python code is often loaded by name.&lt;/p&gt;

</description>
      <category>python</category>
      <category>opensource</category>
      <category>codequality</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
