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.
Recipe
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- Types work at runtime but mypy/pyright report circular imports
- New module addition breaks startup order that "used to work"
- Lazy imports multiply as a coping mechanism
Working Example
# 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:
- Shared value types move to a leaf module both sides import
- Domain types import inward; billing imports
Orderonly afterorders.modelsfinishes loading - Lazy import unblocks emergencies but does not replace structure
Deep Dive
How It Works
- Import runs top-to-bottom once per module; incomplete modules sit in
sys.modules - Attribute access during partial init fails or sees missing names
- Type-checking-only imports (
if TYPE_CHECKING:) break cycles for static tools without runtime cost - Package
__init__.pyre-exports can accidentally create cycles across subpackages
Common Cycle Shapes
| 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 |
Python Notes
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from orders.models import Order
def describe(order: "Order") -> str:
return "order"Gotchas
- String annotations only - quotes hide cycles without fixing architecture. Fix: extract types or invert dependency.
- Fat
__init__.py- importing subpackage runs entire tree. Fix: empty or minimal__init__.py; explicit submodule imports. - Side effects at import - DB connect on import worsens cycle debugging. Fix: move IO to
main/startup hooks. - pytest collection imports everything - cycles hidden until full suite runs. Fix: smoke
import packagein CI. - Renaming without inverting edges - cycle returns with new module names. Fix: dependency rule: domain never imports adapters.
- Circular test conftest imports -
conftest.pyimports app which imports tests helpers. Fix: fixtures in dedicatedtests/supportwithout importing app modules mutually.
Alternatives
| 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 |
FAQs
How do I see the import chain?
Run python -X importtime -c "import pkg" and read the slowest/cyclic edges. Tools like pydeps graph imports statically.
Does from __future__ import annotations fix runtime cycles?
It postpones annotation evaluation (PEP 563 behavior varies by version) but does not fix mutual runtime imports of classes and functions.
Where should Protocol ports live?
ports/ or domain/protocols.py - imported by adapters and use cases, not by frameworks importing each other.
Can circular imports work sometimes?
Accidentally, if one module only needs the other after both finish init. Relies on import order and breaks when refactored.
How do FastAPI routers create cycles?
Routers import services that import app for Depends. Fix: dependency factories in a wiring module imported after app creation.
What about Django models importing each other?
Use string references 'other.Model' in ForeignKey and move shared enums to models/enums.py leaf module.
Should I use importlib in production code?
Rarely. Dynamic imports obscure graphs. Prefer structural fixes; use importlib only for plugins with explicit entry points.
How does src layout help?
Forces explicit package names and catches mistaken relative imports that worsen cycles across duplicate module paths.
Can ruff detect cycles?
ruff lint rules catch some import issues; import-linter contracts enforce layer direction in CI.
When is merging modules correct?
When two modules are <200 lines, always change together, and represent one bounded concept. Document the merge in an ADR.
How do I prevent regressions?
Add python -c "import myservice" to CI and optional import-linter contracts between domain, use_cases, adapters.
Does TYPE_CHECKING affect runtime performance?
No. Imports under if TYPE_CHECKING: are stripped at runtime by type checkers only; they do not execute during normal imports.
Related
- Refactoring Decisions - ranked cycle fixes
- src Layout & Package Structure - package hygiene
- Clean / Hexagonal Architecture - dependency direction
- Debugging Tools - inspect import state
- Dependency & Environment Conflicts - wrong package on path mimics cycles
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+.