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 テクニックではありません。
- Runcell Science:Claude Scienceのオープンソース代替となるAI研究ワークスペース
- Macをスリープさせない方法:Codex・Claude Codeを止めずに動かす
- OpenClaw vs ZeroClaw vs Pi Agent vs Nanobot: 2026年に選ぶべきAIエージェントスタックは?
- Claude CodeでJupyterノートブックを分析する方法|Data Science向けの実践ポイントと限界
- Claude Code Routinesとは?AIエージェントの定期実行と自動化を理解する
- Claude Code DesktopでBypass permissionsを有効にする方法
- GoogleのA2Aプロトコルで2つのPythonエージェントを構築する方法 - ステップバイステップチュートリアル
- 2025年のPythonで人気のあるトップ10のデータ可視化ライブラリ
循環インポートの見え方
最小の失敗例
兄弟モジュールを 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 の実行が終わる前に b が a から名前を取りに来ています。
実際に壊れているポイント
Python がモジュールを import するとき:
- 空のモジュールオブジェクトを作り、
sys.modulesに載せます。 - モジュール本体を上から下へ実行します。
- 本体が終わって初めて、トップレベル名が揃ったと保証できます。
サイクル中は、モジュール A のステップ 2 がまだ走っているのに、B が A の後ろの方で定義される名前を要求します。名前が無い → import エラー。これは 部分初期化の失敗 であって、無限ループではありません。
切り分けフロー
この順で進め、サイクルが消えたら止めます。
-
きれいなエントリポイントで再現する
検索エラーが指すのと同じファイルを実行します(python -m package.moduleまたはスクリプトパス)。循環インポートはエントリポイントに敏感です。 -
トレースバックを下から読む
末尾付近のフレームに、module_yがすでにスタックにあるのにmodule_xがそれを import している、という形が出ることが多いです。 -
一方向の依存スケッチを描く
各モジュールと import 先を列挙します。コアの models からアプリ / UI / API 層へ「上向き」に伸びる辺は疑わしいです。 -
必要なものを分類する
- 実行時の値・関数 → 再構成または遅延インポート
- 型だけ →
TYPE_CHECKING - 共有定数・モデル → 葉(leaf)モジュールへ抽出
-
いちばん小さく正しい修正を当てる
共有モジュール抽出 > 遅延インポート > パッケージ再配置。絶対 import さえすれば直る、という思い込みは避けます。 -
同じエントリポイントで再実行
python a.py系とパッケージの-mの両方を使うなら、両方で確認します。
修正 1: 第 3 モジュールへ切り出す(いちばんの定石)
models と services がどちらも 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")notifications も checkout も相手を 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実務ルール:
- 葉モジュールはアプリ側を import しない。
__init__.pyは薄く保つ。 急ぎのfrom .a import */from .b import *連鎖はサイクル工場になりやすい。- 安定した葉からの明示 import を、パッケージ半分かき集める「便利」再エクスポートより優先する。
- ライブラリ開発ではパッケージとして実行:
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 モジュールへ抽出 |
| 片方の呼び出し経路だけが相手を必要 | その関数内の遅延インポート |
| 型チェッカのためだけの import | TYPE_CHECKING |
| 2 サービスが互いにオーケストレーション | 依存注入 / コールバック / イベント |
| パッチ後もサイクルが戻ってくる | レイヤ規則 + CI での lint |
FAQ
まとめ
循環インポートは依存グラフの問題です。Python はそれを部分初期化エラー、とくに「most likely due to a circular import」というなじみ深いメッセージとして表に出します。依存を一方向にして直します。共有の葉を切り出し、必要なところだけ遅延 import し、注釈用 import は TYPE_CHECKING の後ろに置き、サービス同士が要るときは外側から配線します。
絶対 import、__all__、ファイル名変更といった俗説だけでは本物のサイクルは解けません。グラフがきれいになると、import はまた退屈になります。それが望ましい状態です。
関連ガイド
- Python の型ヒント —
TYPE_CHECKINGと相性の良い注釈パターン - Python pathlib — パッケージ再配置時のパス処理
- Python スクリプトの実行方法 — import 挙動に効くエントリポイント(
python/-m) - Python の try/except — 実行時 import 失敗の扱いと診断
- Python の dataclass — 葉モジュールに置きやすい軽いドメインモデル
- Python のデコレータ — import 時副作用を招きやすいパターン