Introduction
Circular imports occur when module A imports from module B, and module B imports from module A. Python handles this by partially initializing modules, which means the importing module sees an incomplete version of the imported module. This is one of the most common import errors in growing Python codebases.
Symptoms
ImportError: cannot import name 'UserService' from partially initialized module 'services.user'ModuleNotFoundError: No module named 'models'(when the module clearly exists)- Attribute access fails:
ImportError: cannot import name 'DB' from 'config' - Error occurs at application startup, not during runtime
Traceback (most recent call last):
File "app.py", line 1, in <module>
from models.user import User
File "/project/models/user.py", line 2, in <module>
from services.auth import authenticate
File "/project/services/auth.py", line 1, in <module>
from models.user import User
ImportError: cannot import name 'User' from partially initialized module 'models.user'
(most likely due to a circular import)Common Causes
- Two modules that naturally depend on each other's types or functions
- Growing codebase without clear architectural layering
- Importing at module level instead of inside functions
- Shared constants or configuration split across files
- Domain models importing service logic and vice versa
Step-by-Step Fix
- 1.Identify the circular chain:
- 2.```bash
- 3.python -c "import sys; from models.user import User" 2>&1 | grep -A 20 "Traceback"
- 4.
` - 5.Move import inside function (lazy import):
- 6.```python
- 7.# services/auth.py - WRONG (top-level circular import)
- 8.from models.user import User
def authenticate(username): return User.query.filter_by(username=username).first()
# services/auth.py - CORRECT (deferred import) def authenticate(username): from models.user import User # Imported only when called return User.query.filter_by(username=username).first() ```
- 1.Restructure into dependency layers:
- 2.
` - 3.# Before (circular):
- 4.models/user.py -> services/auth.py
- 5.services/auth.py -> models/user.py
# After (layered): models/user.py # No imports from services models/base.py # Shared base classes services/auth.py -> models/user.py (one direction only) ```
- 1.Extract shared types to a separate module:
- 2.```python
- 3.# types/user_types.py
- 4.from dataclasses import dataclass
@dataclass class UserData: username: str email: str
# Both models and services import from types only from types.user_types import UserData ```
- 1.Use TYPE_CHECKING for type hints only:
- 2.```python
- 3.from __future__ import annotations
- 4.from typing import TYPE_CHECKING
if TYPE_CHECKING: from models.user import User # Only used by type checker, not at runtime
def process(user: "User") -> None: pass ```
Prevention
- Enforce layering with
import-linter: - ```bash
- pip install import-linter
- # .import-linter.ini
- [importlinter]
- root_package = myapp
[importlinter:contract:1]
name = "Services may not import Models"
type = forbidden
source_modules = myapp.services
forbidden_modules = myapp.models
``
- Use from __future__ import annotations to enable postponed evaluation
- Keep imports at module level except for breaking circular dependencies
- Run pylint --enable=cyclic-import` in CI