DEV Community

Cover image for Circular imports in Python: why every big Python repo has them, and the few that actually break
Raghav Chamadiya for repowise

Posted on AI-assisted

Circular imports in Python: why every big Python repo has them, and the few that actually break

TL;DR

  • 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.
  • 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 from x import y, a base class, or a decorator that runs on import.
  • 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.
  • Fixes, in order: move the shared code to a third module, import the module instead of the name (with from __future__ import annotations), put type-only imports under TYPE_CHECKING, or import inside the function.

This is the error that sends people searching:

ImportError: cannot import name 'User' from partially initialized module 'shop.models' (most likely due to a circular import)
Enter fullscreen mode Exit fullscreen mode

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.

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.

Reproduce it

Two modules in a package, each importing a name from the other:

# shop/models.py
from shop.services import send_welcome


class User:
    def __init__(self, email):
        self.email = email
Enter fullscreen mode Exit fullscreen mode
# shop/services.py
from shop.models import User


def send_welcome(user: User):
    print(f"welcome, {user.email}")
Enter fullscreen mode Exit fullscreen mode
# main.py
from shop.models import User
from shop.services import send_welcome

send_welcome(User("a@example.com"))
Enter fullscreen mode Exit fullscreen mode
$ python main.py
Traceback (most recent call last):
  File "D:\devto-scratch\repro\shop\main.py", line 1, in <module>
    from shop.models import User
  File "D:\devto-scratch\repro\shop\shop\models.py", line 1, in <module>
    from shop.services import send_welcome
  File "D:\devto-scratch\repro\shop\shop\services.py", line 1, in <module>
    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)
Enter fullscreen mode Exit fullscreen mode

What happens, step by step:

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

"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.

How common are circular imports in Python projects?

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):

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

To see why they do not break, I took a fresh clone of Django (commit 6a8248f) and looked at its import graph. Counting every import between the 900 non-test .py files under django/, 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 django/), from django/__init__.py through apps, conf, db and parts of contrib.

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:

Django, files under django/ Circular groups Files inside a group Largest group
All imports 14 238 179 files
Only imports that run at module load 8 58 35 files

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. django/db/models/query_utils.py has this inside Q.check():

        # Avoid circular imports.
        from django.db.models import Value
        from django.db.models.sql import Query
        from django.db.models.sql.constants import SINGLE
Enter fullscreen mode Exit fullscreen mode

git grep -i "circular import" -- 'django/*.py' finds 16 comments like that in 11 files.

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

# Imports that would create circular imports if sorted
from django.db.models.base import DEFERRED, Model  # isort:skip
from django.db.models.fields.related import (  # isort:skip
    ForeignKey,
    ForeignObject,
    OneToOneField,
Enter fullscreen mode Exit fullscreen mode

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.

Which circular imports raise "partially initialized module"?

1. A module-level from x import y that runs while x is half built

That is the reproduction above. from module import name reads the name immediately. If module is still running higher up the stack, the name does not exist yet.

2. Inheriting from a class across the cycle

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:

# plugins/base.py
from plugins.email import EmailNotifier


class Notifier:
    def send(self, msg):
        raise NotImplementedError


DEFAULT = EmailNotifier
Enter fullscreen mode Exit fullscreen mode
# plugins/email.py
import plugins.base as base


class EmailNotifier(base.Notifier):
    def send(self, msg):
        print("email:", msg)
Enter fullscreen mode Exit fullscreen mode
  File "D:\devto-scratch\repro\inherit\plugins\email.py", line 4, in <module>
    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)
Enter fullscreen mode Exit fullscreen mode

3. Registries and decorators that run on import

The classic web-app layout: app.py imports the views so the routes get registered, and views.py imports app to use its decorator.

# web/app.py
from web import views  # load the routes


class App:
    def __init__(self):
        self.routes = {}

    def route(self, path):
        def register(fn):
            self.routes[path] = fn
            return fn
        return register


app = App()
Enter fullscreen mode Exit fullscreen mode
# web/views.py
from web.app import app


@app.route("/")
def index():
    return "hello"
Enter fullscreen mode Exit fullscreen mode
  File "D:\devto-scratch\repro\registry\web\views.py", line 1, in <module>
    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)
Enter fullscreen mode Exit fullscreen mode

The decorator runs at import time, so it needs app before app.py has created it.

How do you fix a circular import in Python?

All of these were run on Python 3.13.

Move the shared piece to a third module

The best fix removes the cycle. In the inheritance example, base.py only imported EmailNotifier to set a default. Move the default somewhere that sits above both:

# plugins/base.py
class Notifier:
    def send(self, msg):
        raise NotImplementedError
Enter fullscreen mode Exit fullscreen mode
# plugins/defaults.py
from plugins.email import EmailNotifier

DEFAULT = EmailNotifier
Enter fullscreen mode Exit fullscreen mode
$ python main.py
email: hi
Enter fullscreen mode Exit fullscreen mode

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

Import the module, not the name, and defer annotations

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

# shop/services.py
from shop import models


def send_welcome(user: models.User):
    print(f"welcome, {user.email}")
Enter fullscreen mode Exit fullscreen mode
    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)
Enter fullscreen mode Exit fullscreen mode

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

If the import is only for type hints, use TYPE_CHECKING

# shop/services.py
from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from shop.models import User


def send_welcome(user: User):
    print(f"welcome, {user.email}")
Enter fullscreen mode Exit fullscreen mode
$ python main.py
welcome, a@example.com
Enter fullscreen mode Exit fullscreen mode

The import only exists for mypy, pyright and your editor. At runtime it never executes.

Import inside the function

# shop/models.py
class User:
    def __init__(self, email):
        self.email = email

    def welcome(self):
        from shop.services import send_welcome
        send_welcome(self)
Enter fullscreen mode Exit fullscreen mode

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.

How do you find circular imports in your own project?

For a list in the terminal, pylint has a check for exactly this:

pylint --disable=all --enable=cyclic-import django/db
Enter fullscreen mode Exit fullscreen mode

On Django's django/db it reported 72 cyclic-import messages in 8.5 seconds, for example:

django\db\models\sql\__init__.py:1:0: R0401: Cyclic import (django.db.models.manager -> django.db.models.query) (cyclic-import)
django\db\models\sql\__init__.py:1:0: R0401: Cyclic import (django.db.models.base -> django.db.models.fields.related) (cyclic-import)
Enter fullscreen mode Exit fullscreen mode

pylint counts imports inside functions too, so expect it to list cycles that never fail at runtime. pydeps yourpackage --show-cycles 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 this guide to finding circular dependencies.

To look at the cycles on a picture instead of a list, Django's architecture view shows the same import graph, and the trace tool on that page finds the path between any two files.

How I got these numbers

The 51 repos are the Python part of a set of 195 of the most-starred public repositories indexed on repowise (1,000+ GitHub stars each, 253,688 production source files, November 2026), each measured at its own latest index between May and November 2026. The cross-language numbers are in finding 6 of the code health 2026 report. Cycles are counted on file-to-file imports between production files, so __init__.py re-exports count as edges. The Django breakdown comes from repowise init --yes --no-prose --no-editor-setup on a fresh clone at 6a8248f, 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.

FAQ

What does "partially initialized module ... most likely due to a circular import" mean?
Python has created the module object and put it in sys.modules, 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.

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

Are circular imports always a bug in Python?
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.

Top comments (0)