PythonのImportError: cannot import name from partially initialized module(循環インポート)の修正方法

intermediate🐍 Python2026-03-22| Python 3.x、全OS(Linux、macOS、Windows)、複数モジュールを使用するすべてのプロジェクト

Error Message

ImportError: cannot import name 'X' from partially initialized module 'Y' (most likely due to a circular import)
#python#import#circular-import#module

エラーは起動時に発生する

アプリは正常に動いていた。新しいインポートを追加した途端、起動すらできなくなった:

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.pyservices.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つのアプローチのいずれかで、コアロジックに触れることなくこの問題を解決できる。

Related Error Notes