Circular Import Scenarios
Circular imports happen when module A loads module B while B is still initializing and tries to import A. Python raises ImportError or leaves names as half-initialized None-like placeholders.
Search across all documentation pages
Circular imports happen when module A loads module B while B is still initializing and tries to import A. Python raises ImportError or leaves names as half-initialized None-like placeholders.
Quick-reference recipe card - copy-paste ready.
# Diagnose
python -c "import mypackage"
python -X importtime -c "import mypackage" 2>&1 | head
# Structural fix: extract shared types
# mypackage/domain/types.py <- both sides import this leaf moduleWhen to reach for this:
ImportError: cannot import name 'X' from partially initialized module# Broken layout:
# orders/models.py imports billing/invoices.py
# billing/invoices.py imports orders/models.py
# Step 1: reproduce
# $ python -c "import orders.models"
# ImportError: cannot import name 'Order' from partially initialized module 'orders.models'
# Step 2: extract leaf types
# shared/types.py
from dataclasses import dataclass
@dataclass(frozen=True)
class OrderId:
value: str
@dataclass(frozen=True)
class Money:
cents: int
# orders/models.py
from shared.types import Money, OrderId
@dataclass
class Order:
id: OrderId
total: Money
# billing/invoices.py
from shared.types import Money, OrderId
from orders.models import Order
def invoice_for(order: Order) -> Money:
return order.total# Temporary bridge (document ticket to remove)
def get_order(order_id: str):
from orders.models import Order # lazy import inside function
return Order(OrderId(order_id), Money(0))What this demonstrates:
Order only after orders.models finishes loadingsys.modulesif TYPE_CHECKING:) break cycles for static tools without runtime cost__init__.py re-exports can accidentally create cycles across subpackages| Pattern | Symptom | Fix |
|---|---|---|
| A ↔ B mutual imports | startup ImportError | leaf types module |
models ↔ services | missing class name | ports/use cases layer |
app factory ↔ blueprints | Flask/Django init fail | late blueprint registration |
__init__ re-export graph | slow/heavy import | narrow __all__, lazy public API |
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from orders.models import Order
def describe(order: "Order") -> str:
return "order"__init__.py - importing subpackage runs entire tree. Fix: empty or minimal __init__.py; explicit submodule imports.main/startup hooks.import package in CI.conftest.py imports app which imports tests helpers. Fix: fixtures in dedicated tests/support without importing app modules mutually.| Alternative | Use When | Don't Use When |
|---|---|---|
Leaf types/protocols module | shared data shapes | behavior still mutually calls |
| Merge modules temporarily | two tiny intertwined files | large domains - creates god module |
TYPE_CHECKING imports | typing-only back references | runtime needs real class |
| Lazy function imports | hotfix with ticket | permanent design |
Run python -X importtime -c "import pkg" and read the slowest/cyclic edges. Tools like pydeps graph imports statically.
It postpones annotation evaluation (PEP 563 behavior varies by version) but does not fix mutual runtime imports of classes and functions.
ports/ or domain/protocols.py - imported by adapters and use cases, not by frameworks importing each other.
Accidentally, if one module only needs the other after both finish init. Relies on import order and breaks when refactored.
Routers import services that import app for Depends. Fix: dependency factories in a wiring module imported after app creation.
Use string references 'other.Model' in ForeignKey and move shared enums to models/enums.py leaf module.
Rarely. Dynamic imports obscure graphs. Prefer structural fixes; use importlib only for plugins with explicit entry points.
Forces explicit package names and catches mistaken relative imports that worsen cycles across duplicate module paths.
ruff lint rules catch some import issues; import-linter contracts enforce layer direction in CI.
When two modules are <200 lines, always change together, and represent one bounded concept. Document the merge in an ADR.
Add python -c "import myservice" to CI and optional import-linter contracts between domain, use_cases, adapters.
No. Imports under if TYPE_CHECKING: are stripped at runtime by type checkers only; they do not execute during normal imports.
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.
Reviewed by Chris St. John·Last updated Jul 19, 2026