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
bash
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. 1.Identify the circular chain:
  2. 2.```bash
  3. 3.python -c "import sys; from models.user import User" 2>&1 | grep -A 20 "Traceback"
  4. 4.`
  5. 5.Move import inside function (lazy import):
  6. 6.```python
  7. 7.# services/auth.py - WRONG (top-level circular import)
  8. 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. 1.Restructure into dependency layers:
  2. 2.`
  3. 3.# Before (circular):
  4. 4.models/user.py -> services/auth.py
  5. 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. 1.Extract shared types to a separate module:
  2. 2.```python
  3. 3.# types/user_types.py
  4. 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. 1.Use TYPE_CHECKING for type hints only:
  2. 2.```python
  3. 3.from __future__ import annotations
  4. 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