エラーは起動時に発生する
アプリは正常に動いていた。新しいインポートを追加した途端、起動すらできなくなった:
ImportError: cannot import name 'UserService' from partially initialized module 'app.services' (most likely due to a circular import)
「most likely due to a circular import」という括弧書きは、Pythonなりの丁寧な言い方だ。これは間違いなく循環インポートだ。モジュールAがモジュールBからインポートし、モジュールBがモジュールAからインポートしている。PythonはAのロードを開始し、Bのインポートに当たり、Bのロードを開始し、再びAのインポートに当たって、半完成のモジュールを受け取ってしまう。インポートしようとした名前はまだ存在していない。
まず循環を見つける
何も触る前に、最後の行だけでなくトレースバック全体を読む:
Traceback (most recent call last):
File "main.py", line 1, in <module>
from app.routes import user_routes
File "/app/routes.py", line 3, in <module>
from app.services import UserService
File "/app/services.py", line 2, in <module>
from app.models import User
File "/app/models.py", line 4, in <module>
from app.services import UserService
ImportError: cannot import name 'UserService' from partially initialized module 'app.services'
下から上に読む。models.pyがservices.pyからインポートしているが、Pythonがここに来た時点でservices.pyはすでにインポートの途中だった。循環が判明した:services → models → services。
チェーンが明確でない大規模プロジェクトでは、疑わしいモジュールの先頭に一時的なデバッグ行を追加する:
# 一時デバッグ用 — 循環を見つけたら削除すること
import sys
print(f"Loading {__name__}, already loaded: {[m for m in sys.modules if 'app' in m]}")
エントリーポイントを実行してprint順を確認する。数行以内に循環が現れる。
循環を断ち切る3つの方法
方法1:インポートを関数内に移動する
最も手早い修正。モジュールレベルでインポートする代わりに、実際に必要な関数の中でインポートする:
# services.py — 修正前(循環インポートを引き起こす)
from app.models import User
class UserService:
def get_user(self, user_id):
return User.query.get(user_id)
# services.py — 修正後(遅延インポート)
class UserService:
def get_user(self, user_id):
from app.models import User # モジュールレベルではなくここでインポート
return User.query.get(user_id)
Pythonはget_user()が呼ばれた時にのみインポートを実行する。その時点では両方のモジュールが完全にロードされている。最もきれいなパターンではないが、明示的で今すぐ機能する。
方法2:共有コードを第三のモジュールに抽出する
本質的なアーキテクチャの修正。AとBが互いにインポートし合っている場合、ほぼ必ずそれ自身のモジュールに属すべき概念を共有している。それを切り出す:
# 修正前:
# models.py は services.py からインポート
# services.py は models.py からインポート
# 修正後: app/base.py または app/types.py を作成
# base.py
from dataclasses import dataclass
@dataclass
class UserData:
id: int
email: str
# models.py
from app.base import UserData # services のインポートは不要
# services.py
from app.base import UserData # models のインポートは不要
循環インポートはシグナルだった。2つのモジュールの結合が強すぎたのだ。この抽出により、症状だけでなく根本的な設計も修正される。
方法3:型ヒントにTYPE_CHECKINGを使う
現代のPythonにおける循環インポートの多くは、型アノテーションのためだけに存在する — そのインポートは実行時には不要だ。TYPE_CHECKINGでガードする:
from __future__ import annotations # 実行時にすべてのアノテーションを文字列にする
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.services import UserService # 型チェック時のみインポート
class User:
def get_service(self) -> 'UserService': # 正常に動作
...
TYPE_CHECKINGは実行時には常にFalseだ — mypyとpyrightは静的解析時にTrueとして扱う。実行時の循環インポートはなく、チェック時の型安全性は完全に保たれる。
TYPE_CHECKINGパターンの完全な例
このパターンは実際のプロジェクトで驚くほど多くの循環を解消するため、修正前後の完全な例を示す価値がある:
# 修正なし — 循環インポート
# order.py
from app.customer import Customer
class Order:
def __init__(self, customer: Customer):
self.customer = customer
# customer.py
from app.order import Order
class Customer:
def get_orders(self) -> list[Order]:
...
# 修正後
# order.py
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.customer import Customer
class Order:
def __init__(self, customer: Customer): # アノテーションは文字列になる
self.customer = customer
# customer.py
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.order import Order
class Customer:
def get_orders(self) -> list[Order]: # 正常に動作
...
修正を確認する
再起動して祈るだけでは不十分だ。プロジェクトルートから各モジュールを単独でテストする:
python -c "from app.services import UserService; print('OK')"
python -c "from app.models import User; print('OK')"
python -c "from app.routes import user_routes; print('OK')"
次に全テストスイートを実行する:
python -m pytest tests/ -x # -x は最初の失敗で停止
インポートの循環を自動的に検出するには、pylintに組み込みの循環検出機能がある:
pip install pylint
pylint app/ --disable=all --enable=R0401 # R0401 = cyclic-import
再発を防ぐ
大きなチームでは循環インポートが忍び込んでくる。これを防ぐためのいくつかの習慣:
- インポートを階層化する:依存関係の方向を決め(例:routes → services → models → base)、上位への逆方向インポートを禁止する
- CIに循環検出を追加する:パイプラインで
pylint --enable=R0401を実行し、マージ前に循環を検出する __init__.pyをシンプルに保つ:パッケージの__init__.pyからすべてを再エクスポートするのが、隠れた循環の最も一般的な原因だ- アノテーションにはデフォルトで
TYPE_CHECKINGを使う — コードレビューのチェックリストに追加する
まだ直らない?.pycキャッシュを確認する
循環が解消されたのにエラーが続く場合、古いバイトコードが以前のインポートグラフを再現している可能性がある。クリアする:
find . -name '*.pyc' -delete
find . -name '__pycache__' -type d -exec rm -rf {} +
python main.py
十中八九、上記の3つのアプローチのいずれかで、コアロジックに触れることなくこの問題を解決できる。

