Python circular imports: what actually happens and how to fix them
A Python circular import fails because the module is in sys.modules before its code runs. See the exact errors, why import a works, and fixes ranked best first.
By the pyrovue team · · 11 min read
A circular import in Python happens when two modules import each other, directly or through a chain, and one of them asks for a name the other has not defined yet. Python adds a module to sys.modules before it runs the module's code, so the second import gets the half-built module back instead of loading it again. If that module has not reached the line that defines the name, you get ImportError: cannot import name 'User' from partially initialized module 'shop.models' (most likely due to a circular import), or an AttributeError with the same hint.
The fix that lasts is to make the dependency run one way: move the code both modules need into a third module, or pass the dependency in. Importing inside a function, writing import module instead of from module import name, and guarding type-only imports with typing.TYPE_CHECKING are quicker fixes, and each one works only under specific rules, which are spelled out below.
Every Python example here was run on CPython 3.11, 3.12, 3.13, 3.14.10 and 3.15.2, the newest release as of November 2026. Where versions behave differently, the text says which.
What Python does when two modules import each other
Take a package where shop/models.py defines User, whose register() method sends a welcome email, and shop/services.py defines the email function, which takes a User. shop/__init__.py is empty.
# shop/models.py
from shop.services import send_welcome_email
class User:
def __init__(self, email: str):
self.email = email
def register(self) -> None:
send_welcome_email(self)
# shop/services.py
from shop.models import User
def send_welcome_email(user: User) -> None:
print(f"Welcome, {user.email}")
This is what import shop.models does:
- Python imports the
shoppackage, then looks forshop.modelsinsys.modules, the cache of loaded modules. It is not there. - It creates an empty module object, stores it in
sys.modules["shop.models"], and starts runningmodels.pyfrom the top. - Line 1 asks for
shop.services. That is not cached either, so Python creates it, caches it and starts runningservices.py, leavingmodels.pypaused on line 1. - Line 1 of
services.pyasks forUserfromshop.models. That module is in the cache, so Python does not run it again (this is what stops two modules importing each other forever). It looks upUseron the cached object and finds nothing, becausemodels.pynever got past its first line. - Python raises
ImportError, removes the modules that failed fromsys.modules, and the error travels back to yourimport.
The import system reference states the rule behind step 2: a module exists in sys.modules before the loader executes its code. You can watch it by adding two lines to the top of services.py:
import sys
print(hasattr(sys.modules["shop.models"], "User")) # prints False
The two errors, reproduced
ImportError: cannot import name from partially initialized module
Run the two files above from the folder that contains shop/:
python -c "import shop.models"
Output on Python 3.15.2, with the path shortened:
Traceback (most recent call last):
File "<string>", line 1, in <module>
import shop.models
File "/home/you/project/shop/models.py", line 1, in <module>
from shop.services import send_welcome_email
File "/home/you/project/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) (/home/you/project/shop/models.py)
Python 3.11 to 3.14 print the same last line. Two details catch people out.
- The module named in the error depends on import order. Run
python -c "import shop.services"and the error becomescannot import name 'send_welcome_email' from partially initialized module 'shop.services'. Whichever module is entered first is left half-built, so a test that imports modules in a different order from your app can pass while the app fails. - Python 3.13 and later word it differently for top-level files. If the half-built module is a top-level module or package sitting in the script's folder (
sys.path[0]), for examplemodels.pyandservices.pynext tomain.pywith no package, the message becomesImportError: cannot import name 'User' from 'models' (consider renaming '/home/you/project/models.py' if it has the same name as a library you intended to import). It is the same cycle. CPython adds that hint because, from inside the import, it cannot tell a cycle apart from a local file shadowing an installed library.
AttributeError: partially initialized module has no attribute
Switch both files to module imports and add one line that uses User while services.py is loading:
# shop/models.py
from shop import services
class User:
def __init__(self, email: str):
self.email = email
def register(self) -> None:
services.send_welcome_email(self)
# shop/services.py
from shop import models
SYSTEM_USER = models.User("noreply@example.com")
def send_welcome_email(user: models.User) -> None:
print(f"Welcome, {user.email}")
Run python -c "import shop.models" again:
File "/home/you/project/shop/services.py", line 3, in <module>
SYSTEM_USER = models.User("noreply@example.com")
^^^^^^^^^^^
AttributeError: partially initialized module 'shop.models' from '/home/you/project/shop/models.py' has no attribute 'User' (most likely due to a circular import)
That is the wording on 3.13 and later; 3.11 and 3.12 leave out the from '<path>' part. The import statement succeeded this time. The failure moved to the first line that reads an attribute from the half-built module.
Why import a works when from a import X fails
from shop.models import User has to find User at the moment the statement runs, which is in the middle of the cycle. from shop import models, import shop.models and import shop.models as models only bind a name to the module object, which exists even when half-built. Attributes are looked up later, when the code using them runs, and by then both modules have finished.
So the module form avoids the error only if nothing reads the other module's attributes while it is loading. All of these read them at load time:
- module-level statements, like
SYSTEM_USER = models.User(...)above - base classes (
class Admin(models.User):), decorators and default argument values - annotations, on Python 3.13 and earlier
The last one surprises people. Delete the SYSTEM_USER line and the example still fails on 3.11, 3.12 and 3.13 with the same AttributeError, because the annotation user: models.User is evaluated when the def runs. On 3.14 and later it works, because annotations are evaluated only when something asks for them (PEP 649 and PEP 749).
How to fix a circular import in Python, best fix first
1. Move the shared code into a third module
A cycle usually means two modules each hold half of one idea. Here, send_welcome_email only needs something with an email attribute, so it can live in a module that imports nothing from shop:
# shop/emails.py
from typing import Protocol
class HasEmail(Protocol):
email: str
def send_welcome_email(user: HasEmail) -> None:
print(f"Welcome, {user.email}")
models.py imports from shop.emails, and whatever needs User imports shop.models. The arrows run one way, so import order stops mattering. This is the only fix that removes the cycle instead of working around it.
2. Invert the dependency
When a lower-level module needs to call back up, pass the function in instead of importing it:
# shop/models.py: imports nothing from shop
from collections.abc import Callable
class User:
def __init__(self, email: str):
self.email = email
def register(self, notify: Callable[["User"], None]) -> None:
notify(self)
The caller in services.py writes user.register(notify=send_welcome_email). Frameworks build the same idea in: Django's relationship fields accept a model name as a string, such as ForeignKey("shop.User", ...), so two apps' models never have to import each other.
3. Import inside the function
class User:
def __init__(self, email: str):
self.email = email
def register(self) -> None:
from shop.services import send_welcome_email
send_welcome_email(self)
The import runs on the first call, when both modules are loaded, and costs a dictionary lookup in sys.modules after that. It works whichever module is imported first. The price is a dependency that readers of the file's header can't see: pylint flags it as C0415 import-outside-toplevel and still reports R0401 cyclic-import, because the cycle is still there. Moving the import to the bottom of models.py is weaker: it works when models is imported first and fails when services is.
4. Use import module on every side
Change both files to the module form, keep attribute access inside functions, and on Python 3.13 or earlier add from __future__ import annotations to any file whose annotations name the other module. Changing only one side is not enough: with from shop import services in models.py and from shop.models import User left in services.py, import shop.models still fails while import shop.services works.
5. Guard type-only imports with TYPE_CHECKING
Often one side needs the other only for type hints. typing.TYPE_CHECKING is False at runtime and assumed True by type checkers:
# shop/services.py
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from shop.models import User
def send_welcome_email(user: User) -> None:
print(f"Welcome, {user.email}")
This works on 3.11 to 3.15. Without the __future__ line it raises NameError: name 'User' is not defined on 3.13 and earlier and works on 3.14 and later. The limit: code that reads annotations at runtime needs the real class. typing.get_type_hints(send_welcome_email) raises the same NameError on every version, and libraries that build behaviour from annotations, such as Pydantic and FastAPI, hit the same wall.
6. Lazy imports, and when they are fine
Python 3.15 added explicit lazy imports (PEP 810). Writing lazy from shop.services import send_welcome_email at the top of models.py defers the load until the name is first used, which fixes this example on 3.15.2. The lazy keyword is a SyntaxError on 3.14, and the compatibility form, __lazy_modules__ = ["shop.services"], is ignored before 3.15, so the cycle still fails there. If the lazy name is used during loading (the SYSTEM_USER line), you get the same AttributeError. PEP 810 says it plainly: lazy imports don't automatically solve circular import problems. They are fine for what they were built for, deferring heavy imports to cut startup time. As a cycle fix they are option 3 with tidier syntax.
How to find circular imports in a large project
Start with the traceback. The frames between the first line that imports the module named in the error and the last line form the loop: above, models.py line 1 imports services.py, whose line 1 imports shop.models again. A longer cycle (a imports b imports c imports a) shows as more frames in the same pattern.
When the traceback is long, or the error moves as you change things, log the imports:
# one line per module as it finishes loading, indented by depth
python -X importtime -c "import shop.models" 2> imports.txt
# Python 3.14 and later: also log imports of modules already in sys.modules
python -X importtime=2 -c "import shop.models" 2> imports.txt
-X importtime is a timing tool, but its nesting shows who imported whom. With =2, a cached line for shop.models that appears before the line where shop.models itself finishes is the import that closed the loop.
To see every cycle at once, use a static tool:
pip install pydeps pylint import-linter
pydeps shop --show-cycles # SVG of only the cycles; needs Graphviz
pydeps shop --show-cycles --show-dot --no-output # the same graph as DOT text
pylint --disable=all --enable=cyclic-import shop # R0401 lines, one per cycle
For CI, an import-linter acyclic_siblings contract fails the build when a cycle appears:
# pyproject.toml
[tool.importlinter]
root_package = "shop"
[[tool.importlinter.contracts]]
name = "No cycles between modules in shop"
type = "acyclic_siblings"
ancestors = ["shop"]
lint-imports then exits with status 1 and names the import to remove, here .services -> .models (1 import). If you use fix 5, add exclude_type_checking_imports = true under [tool.importlinter]. pydeps is the quicker way to look; import-linter is the one to keep.
The tools disagree on which imports count, which is why one can report a cycle in code that runs fine:
| Tool | Imports inside functions | Imports under if TYPE_CHECKING: |
|---|---|---|
pydeps --show-cycles | counted | counted |
pylint cyclic-import | counted | not counted |
| import-linter | counted | counted unless exclude_type_checking_imports = true |
A static list is a list of places where modules depend on each other, not a list of crashes. Every entry is still a spot where a new import or a different import order can break startup.
The same problem in JavaScript and TypeScript
ES modules hit the same wall with a different error. Imports are hoisted live bindings, so the import line itself doesn't fail in a cycle, but an exported class, const or let sits in the temporal dead zone until its module has run. Port the SYSTEM_USER example to models.mjs and services.mjs and Node 22 prints ReferenceError: Cannot access 'User' before initialization. Exported function declarations are initialised before any module code runs, which is why some JavaScript cycles work until someone turns a function into an arrow function. CommonJS hands back the half-filled module.exports object instead, so the same cycle gives TypeError: User is not a constructor.
The fixes are the same as in Python. To list the cycles:
npx madge --circular --extensions ts,tsx src/
madge exits with status 1 when it finds one, so the same command works as a CI step.
The shortcut: see a repository's cycles before you read it
When the cycle is in a codebase you don't know yet, pyrovue's Code Map lists it for you. Paste a link to a public GitHub repository and pyrovue starts it in a box, then reads the imports in up to 4,000 source files in about two seconds, with no AI and no model calls. It resolves Python packages, relative imports and src/ layouts, and lists up to 25 import cycles (found as strongly connected components with Tarjan's algorithm) next to the hotspots, the files the rest of the code depends on most. The Python project page covers how the box installs and starts the app, so you can reproduce the traceback in its terminal.
It has the limits of any static reader: imports are read with patterns rather than a full parser, so an import built at runtime (importlib.import_module with a computed name) is missed, and like pydeps it can't tell you which cycles crash. For enforcing a rule in CI, import-linter is the better tool. For finding your way around an unfamiliar project, the guide to understanding a new codebase uses the same map.
Keep new cycles out
- Run the import-linter contract (or madge for JavaScript) on every pull request, so a change that adds a cycle fails before review. For the rest of a pre-merge routine, see how to test a pull request.
- Give packages a direction. An import-linter
layerscontract, such aslayers = ["shop.services", "shop.models"], lets higher layers import lower ones and fails onshop.models -> shop.services, which rules out cycles between layers by construction. - Watch package
__init__.pyfiles that re-export names. Ifshop/__init__.pyimports fromshop.modelsandshop.services, andmodels.pydoesfrom shop import send_welcome_email, the package works with theservicesline first and fails with themodelsline first. An import sorter putsshop.modelsfirst alphabetically, so the cycle appears in a commit that only reordered imports. Import from the module that defines the name.