ImportError: cannot import name 'User' from partially initialized module 'app.models' (most likely due to a circular import). If you write Python for long enough, this error finds you. It usually arrives after a refactor that looked harmless, in a module nobody touched, and the traceback points everywhere except the actual problem.
This is the Python companion to our understanding circular dependencies post (the JavaScript version is here). It covers what the interpreter actually does when it hits a cycle, how to find every cycle in a codebase in one command, and the four patterns that fix them.
What Python actually does with a cycle
Python executes a module top to bottom the first time it is imported, and caches the module object in sys.modules before that execution finishes. That ordering is the whole story.
Say orders.py starts with from app.users import get_user, and users.py starts with from app.orders import Order. Import orders first and this happens:
- Python registers
app.ordersinsys.modulesas an empty module object and starts executing it. - Line one triggers the import of
app.users, which starts executing. - Line one of
users.pyasks forOrderfromapp.orders. The module object exists insys.modules, but execution stopped at line one, soOrderis not defined on it yet. ImportError: cannot import name 'Order' from partially initialized module.
Two details worth knowing. First, import app.orders (module form) survives cycles that from app.orders import Order (name form) does not, because the name lookup is deferred until the attribute is used. Second, the error depends on entry point: the same cycle can crash under pytest but work fine under python main.py, because the import graph is walked in a different order. A cycle that "only breaks in tests" is still a cycle. The full mechanics are in the Python import system reference.
Where cycles come from in real Python codebases
Four shapes account for nearly all of them.
Mutual model imports. models/user.py needs Order for a relationship, models/order.py needs User for the same relationship. Every ORM codebase grows this one eventually.
The fat __init__.py. A package __init__.py that imports every submodule for convenience is Python's version of the barrel file. Any submodule that then imports from its own package (from app.models import User inside app/models/order.py) routes through __init__.py and creates a cycle.
Type hints. def process(order: Order) -> Invoice: forces a runtime import of both classes, even though the annotation is never executed as logic. Type-hint cycles are the most common kind in modern annotated codebases, and the easiest to fix.
Utility creep. A utils.py or helpers.py that started generic and gradually imported domain modules. Once utils imports from models and models imports from utils, everything that touches either is inside the ring.
Find every cycle in one command
Do not fix cycles by chasing tracebacks. Enumerate them first.
pylint. The cyclic-import check (R0401) ships with pylint and needs no configuration:
pylint app/ --disable=all --enable=cyclic-import
import-linter. The better tool for keeping cycles out permanently. You declare contracts in config and it fails CI when they break. The independence and layers contract types both forbid cycles between the modules they cover:
[importlinter]
root_package = app
[importlinter:contract:layers]
name = Layered architecture
type = layers
layers =
app.api
app.services
app.models
Run lint-imports in CI and a new cycle fails the build the moment it lands. The import-linter documentation covers the contract types.
A codebase that has never checked usually has between two and twenty cycles. List them, then apply the fixes below in order of cheapness.
Fix one: make type-hint cycles free
If the only reason for the import is an annotation, the fix costs two lines. Guard the import with TYPE_CHECKING, which is False at runtime, and use the deferred annotation behaviour so the name is never evaluated:
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.orders import Order
def process(order: Order) -> None:
...
The import now exists only for mypy, pyright and your editor. At runtime it never happens, so the cycle is gone. In an annotated codebase this single pattern typically clears half the cycle list in one pass. Ruff's TCH rules will even move eligible imports into the TYPE_CHECKING block automatically.
Fix two: extract the shared module
When two modules both need the same class, constant or schema at runtime, neither should own it. Pull it into a third module both import:
# Before: app/users.py and app/orders.py import each other
# After:
# app/schemas.py
class OrderSummary(TypedDict):
order_id: str
total: int
# app/users.py
from app.schemas import OrderSummary
# app/orders.py
from app.schemas import OrderSummary
This is the same "pull the shared contract up" move as in any language, and it usually improves the design: the cycle was the codebase telling you a boundary was drawn in the wrong place.
Fix three: defer the import into the function
Moving an import from module level to inside the function that uses it breaks the cycle, because by the time the function is called, both modules have finished initialising:
def notify_user(order_id: str) -> None:
from app.users import get_user # deferred, cycle resolved by call time
user = get_user(order_id)
Be honest about what this is: a workaround, not a fix. The structure still has a cycle; you have just hidden it from the import walker. It is the right call when the alternative is restructuring a package mid-incident, and the wrong call as a permanent pattern. Leave a comment, file the debt, and prefer fixes one and two when you have time.
Fix four: empty the __init__.py
The convenience-import __init__.py gives you slightly shorter import paths and, in exchange, cycles, slower startup and eager loading of everything in the package. The trade is bad.
Two rules keep packages cycle-free:
- Inside a package, import siblings directly.
app/models/order.pyimportsfrom app.models.user import User, neverfrom app.models import User. - Keep
__init__.pyempty or close to it. If external callers need a curated public surface, re-export a handful of names and stop there. Nothing inside the package may import from its own__init__.py.
In package-heavy codebases this is the highest-leverage structural fix, for exactly the same reason killing barrel files is in TypeScript.
Keep them out
Fixing the current list is an afternoon. Staying at zero is a CI rule.
- Run pylint's
cyclic-importor import-linter across the codebase and record the count. - Clear the type-hint cycles with
TYPE_CHECKING(cheapest, biggest batch). - Extract shared contracts for the real runtime cycles.
- Strip convenience imports out of
__init__.pyfiles. - Add
lint-importsto CI so the count can never rise again.
Import cycles are one of the structural signals Implera's architecture domain scores on every analysis, alongside change coupling and module structure, so a new ring shows up as a score drop rather than a Friday-afternoon ImportError. The wider structural playbook is in how to improve your codebase architecture.