Skip to content
トピック
Python
Python 循環インポート(circular import)の直し方と実例

Python 循環インポート(circular import)の直し方と実例

公開日

更新日

循環インポート(circular import) は、モジュール A が B を import し、B も(直接、または連鎖経由で)A を import しているときに起きます。Python が無限ループで固まることはまずありません。代わりに 部分初期化(partial initialization) が起きます。一方のモジュールがまだ読み込み中なのに、もう一方がその名前を参照しようとして ImportError / AttributeError が出たり、半端に初期化されたオブジェクトが残ったりします。

すぐ直す(ここから)

状況まずやること
2 モジュールがトップレベルで相互 import共有コードを双方が import できる第 3 モジュールへ切り出す
ある関数の中だけで名前が必要その関数内で import(遅延インポート)
型注釈だけが必要from typing import TYPE_CHECKING(必要なら注釈をクォート)
深いサイクルが残る大きなパッケージ下位層が上位層を import しない依存方向に再設計

すぐに動くことが多い最小パターン:

# Instead of a top-level cycle:
# models.py imports services.py
# services.py imports models.py
 
# services.py — import only where used
def process_user(user_id: int):
    from models import User  # lazy import breaks the load-time cycle
    return User.get(user_id)

「パッチ感がある」ならこの先も読んでください。本質的な修正はほぼいつも 依存の向き であって、巧妙な import テクニックではありません。

循環インポートの見え方

最小の失敗例

兄弟モジュールを 2 つ作ります。

# a.py
import b
 
def hello_from_a():
    return "A"
 
print("a loaded", b.hello_from_b())
# b.py
import a
 
def hello_from_b():
    return "B"
 
print("b loaded", a.hello_from_a())

実行:

python a.py

典型的な失敗(文言は Python バージョンで多少違います):

ImportError: cannot import name 'hello_from_a' from partially initialized module 'a'
(most likely due to a circular import)

このメッセージが、検索クエリとしてもよく出てくる形です。Python は a の読み込みを始め、それが b を読み込み、a の実行が終わる前に ba から名前を取りに来ています。

実際に壊れているポイント

Python がモジュールを import するとき:

  1. 空のモジュールオブジェクトを作り、sys.modules に載せます。
  2. モジュール本体を上から下へ実行します。
  3. 本体が終わって初めて、トップレベル名が揃ったと保証できます。

サイクル中は、モジュール A のステップ 2 がまだ走っているのに、B が A の後ろの方で定義される名前を要求します。名前が無い → import エラー。これは 部分初期化の失敗 であって、無限ループではありません。

切り分けフロー

この順で進め、サイクルが消えたら止めます。

  1. きれいなエントリポイントで再現する
    検索エラーが指すのと同じファイルを実行します(python -m package.module またはスクリプトパス)。循環インポートはエントリポイントに敏感です。

  2. トレースバックを下から読む
    末尾付近のフレームに、module_y がすでにスタックにあるのに module_x がそれを import している、という形が出ることが多いです。

  3. 一方向の依存スケッチを描く
    各モジュールと import 先を列挙します。コアの models からアプリ / UI / API 層へ「上向き」に伸びる辺は疑わしいです。

  4. 必要なものを分類する

    • 実行時の値・関数 → 再構成または遅延インポート
    • 型だけ → TYPE_CHECKING
    • 共有定数・モデル → 葉(leaf)モジュールへ抽出
  5. いちばん小さく正しい修正を当てる
    共有モジュール抽出 > 遅延インポート > パッケージ再配置。絶対 import さえすれば直る、という思い込みは避けます。

  6. 同じエントリポイントで再実行
    python a.py 系とパッケージの -m の両方を使うなら、両方で確認します。

修正 1: 第 3 モジュールへ切り出す(いちばんの定石)

modelsservices がどちらも User を必要とするなら、互いに import させません。上位層に依存しない葉モジュールに User を置きます。

app/
  models/user.py      # leaf: no import from services
  services/billing.py # imports models.user
  api/routes.py       # imports services
# models/user.py
class User:
    def __init__(self, user_id: int, email: str):
        self.user_id = user_id
        self.email = email
 
    @classmethod
    def get(cls, user_id: int) -> "User":
        return cls(user_id, f"user{user_id}@example.com")
# services/billing.py
from models.user import User
 
def invoice(user_id: int) -> str:
    user = User.get(user_id)
    return f"Invoice for {user.email}"
# api/routes.py
from services.billing import invoice
 
def handle(user_id: int) -> str:
    return invoice(user_id)

依存は一方向です: api → services → models。下位が上位を import しないのでサイクルは消えます。

修正 2: 遅延(ローカル)インポート

一時的なサイクルをほどきにくいとき、必要なシンボルを使う関数・メソッドの中で import します。

# reporters.py
def build_report(order_id: str) -> dict:
    from orders import Order  # imported at call time, not module load time
 
    order = Order.load(order_id)
    return {"order_id": order.id, "total": order.total}
# orders.py
class Order:
    def __init__(self, id: str, total: float):
        self.id = id
        self.total = total
 
    @classmethod
    def load(cls, order_id: str) -> "Order":
        return cls(order_id, 19.99)
 
    def pretty(self) -> str:
        from reporters import build_report  # only if you truly need this edge
        return str(build_report(self.id))

遅延インポートが向いている場面

  • レガシーで頑固なサイクルをすぐ切る
  • 重いオプション依存を、必要なコードパスでのみ読み込む

長期設計として避けたい場面

  • インポートコストが効くホットパス(通常は小さいが計測は可能)
  • 新しいサイクルが増え続けるアーキテクチャ。その場合はモジュール抽出を優先

修正 3: 注釈専用の import に TYPE_CHECKING

import の理由が型ヒントだけなら、実行時から外します。

from __future__ import annotations
from typing import TYPE_CHECKING
 
if TYPE_CHECKING:
    from models.user import User  # not imported at runtime
 
def notify(user: User, message: str) -> None:
    print(user.email, message)

from __future__ import annotations(または "User" のようにクォートした注釈)があれば、実行時に User がなくても注釈として成立します。型ヒントのサイクル向けの道具であり、User のメソッド呼び出しには使えません。

修正 4: import せず依存を注入する

サービス同士が「お互いが必要」なら、グローバル import ではなく、協調オブジェクトを渡します。

# notifications.py
class Notifier:
    def send(self, email: str, body: str) -> None:
        print(f"to={email} body={body}")
 
 
# checkout.py
class Checkout:
    def __init__(self, notifier: "Notifier"):
        self.notifier = notifier
 
    def complete(self, email: str) -> None:
        # business logic...
        self.notifier.send(email, "Order complete")
# main.py
from notifications import Notifier
from checkout import Checkout
 
checkout = Checkout(Notifier())
checkout.complete("a@example.com")

notificationscheckout も相手を import しません。配線は端の main.py が持ちます。大きなアプリやフレームワークのコンストラクタ注入と同じ考え方です。

よくある罠(サイクルを直さないアドバイス)

古い解説でよく見ます。別の問題には効きますが、循環インポートには効きません。

見かける助言実際
「絶対 import を使え」パスは読みやすくなる。相互依存そのものは消えない。
__all__ を設定しろ」from module import * の公開面だけ。サイクルには無関係。
「常に importlib.import_module読み込みを遅らせられるが、タイミングが悪ければサイクルは残る。明示的なローカル import か再構成を優先。
「Python が無限ループする」通常は ImportError / 部分初期化であり、スピンし続けない。
「ファイル名を変えれば」名前衝突で紛らわしいエラーは出るが、本当の A↔B 依存はリネームだけでは直らない。

サイクルを防ぐパッケージ配置

健全なパッケージは DAG(有向非巡回グラフ)に近いです。

package/
  __init__.py          # keep thin; avoid importing everything eagerly
  domain/              # pure models, no IO
  services/            # use domain
  adapters/            # DB, HTTP, filesystem
  api/                 # entrypoints; imports services only

実務ルール:

  1. 葉モジュールはアプリ側を import しない。
  2. __init__.py は薄く保つ。 急ぎの from .a import * / from .b import * 連鎖はサイクル工場になりやすい。
  3. 安定した葉からの明示 import を、パッケージ半分かき集める「便利」再エクスポートより優先する。
  4. ライブラリ開発ではパッケージとして実行: python -m package.api は、本当の依存問題を隠すパスハックを避けやすい。Python スクリプトの実行も参照。

リファクタ中にファイルをよく動かすなら、pathlib の方が文字列パスの継ぎはぎより分かりやすいです。

デバッグ道具

サイクルの入口を追う

# debug_import.py
import sys
import trace
 
tracer = trace.Trace(count=False, trace=True)
tracer.runfunc(lambda: __import__("your_package.entry"))

日常作業ではトレースバックで十分なことが多いです。大規模コードでは次も有用です。

  • python -X importtime -c "import your_package" — import 順とコスト
  • import-linter(サードパーティ)— 「services は api を import してはいけない」を CI ルールに
  • pyright / mypy — 注釈利用時の TYPE_CHECKING ミスを早く検知

部分初期化の症状をテストで拾う

def test_package_imports_cleanly():
    import importlib
    import your_package.api as api
 
    importlib.reload(api)  # optional stress
    assert hasattr(api, "handle")

単体テストが小さなモジュールだけを個別に import すると、アプリのエントリ経由でのみ現れるサイクルを見逃します。本番に近いエントリモジュールを 1 本 import するスモークを足します。

判断表: どの修正を選ぶか

シグナル優先する手段
双方が使う共有モデル・定数第 3 モジュールへ抽出
片方の呼び出し経路だけが相手を必要その関数内の遅延インポート
型チェッカのためだけの importTYPE_CHECKING
2 サービスが互いにオーケストレーション依存注入 / コールバック / イベント
パッチ後もサイクルが戻ってくるレイヤ規則 + CI での lint

FAQ

まとめ

循環インポートは依存グラフの問題です。Python はそれを部分初期化エラー、とくに「most likely due to a circular import」というなじみ深いメッセージとして表に出します。依存を一方向にして直します。共有の葉を切り出し、必要なところだけ遅延 import し、注釈用 import は TYPE_CHECKING の後ろに置き、サービス同士が要るときは外側から配線します。

絶対 import、__all__、ファイル名変更といった俗説だけでは本物のサイクルは解けません。グラフがきれいになると、import はまた退屈になります。それが望ましい状態です。

関連ガイド