Python Circular Import: How to Fix It (With Working Examples)
Published on
Updated on
A circular import happens when module A imports module B while B (directly or through a chain) also imports A. Python usually does not hang in an infinite loop. You get a partial import instead: one module is still loading when the other tries to read a name from it, which raises ImportError / AttributeError, or produces half-initialized objects.
Quick fix (start here)
| Situation | What to do first |
|---|---|
| Two modules import each other at top level | Move shared code into a third module both can import |
| You only need a name inside one function | Import inside that function (lazy import) |
| You only need types for annotations | Use from typing import TYPE_CHECKING and quote annotations if needed |
| Large package with deep cycles | Redesign dependencies so lower layers never import upper layers |
Minimal pattern that often unblocks you immediately:
# 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)If that feels like a patch, keep reading. The durable fix is almost always dependency direction, not a clever import trick.
- Runcell Science: An Open Source Alternative to Claude Science for Research Workflows
- How to Make Mac Not Sleep: Keep Codex, Claude Code, and AI Agents Running
- OpenClaw vs ZeroClaw vs Pi Agent vs Nanobot: Which AI Agent Stack Should You Choose in 2026?
- Can Claude Code Analyze Jupyter Notebooks for Data Science? What It Actually Does
- Claude Code Routines: Why AI Agent Cron Jobs Matter
- Claude Code Desktop Bypass Permissions: How to Enable It
- How to Build Two Python Agents with Google’s A2A Protocol - Step by Step Tutorial
- Top 10 growing data visualization libraries in Python in 2025
What a circular import looks like
Minimal failing example
Create two sibling modules:
# 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())Run:
python a.pyTypical failure (wording varies by Python version):
ImportError: cannot import name 'hello_from_a' from partially initialized module 'a'
(most likely due to a circular import)That message is the search-query form of this problem. Python started loading a, which started loading b, which tried to finish importing from a before a finished executing.
What actually goes wrong
When Python imports a module:
- It creates an empty module object and puts it in
sys.modules. - It runs the module body top to bottom.
- Only after that body finishes are all top-level names guaranteed to exist.
During a cycle, step 2 for module A is still running when B asks for something defined later in A. The name is missing → import error. That is a partial initialization failure, not an endless loop.
Troubleshooting flow
Use this order. Stop when the cycle is gone.
-
Reproduce with a clean entrypoint
Run the same file Google/search errors mention (python -m package.moduleor the script path). Circular imports are entrypoint-sensitive. -
Read the full traceback bottom-up
The last few frames usually showmodule_ximportingmodule_ywhilemodule_yis already on the stack. -
Draw a one-direction dependency sketch
List each module and what it imports. Any edge that points “upward” toward app/UI/API layers from core models is a suspect. -
Classify the need
- Runtime value/function → restructure or lazy import
- Type only →
TYPE_CHECKING - Shared constants/models → extract to a leaf module
-
Apply the smallest correct fix
Prefer extract shared module > lazy import > redesign package layout. Avoid cargo-cult “absolute imports will fix it.” -
Re-run from the same entrypoint
Confirm bothpython a.pystyle and package-style-mentrypoints if you use both.
Fix 1: Extract a third module (best default)
When models and services both need User, do not make them import each other. Put User in a leaf module that depends on nothing from the higher layers.
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)Dependency direction is one-way: api → services → models. Cycles disappear because nothing lower imports anything higher.
Fix 2: Lazy (local) imports
Import inside the function or method that needs the symbol when a temporary cycle is hard to untangle.
# 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))When lazy imports are appropriate
- Breaking a stubborn cycle quickly in legacy code
- Optional heavy dependencies (import only on the code path that needs them)
When to avoid them as the long-term design
- Hot paths where import cost matters (usually minor, but measurable)
- Architecture that keeps growing new cycles; extract modules instead
Fix 3: TYPE_CHECKING for annotation-only imports
If the only reason for the import is type hints, keep it out of runtime:
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)With from __future__ import annotations (or quoted "User" annotations), Python does not need User at runtime for the annotation to work. This is the right tool for type hints cycles, not for calling methods on User.
Fix 4: Inject dependencies instead of importing them
For services that “need each other,” pass collaborators in instead of importing globally.
# 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")Neither notifications nor checkout imports the other. Composition at the edge (main.py) owns wiring. Same idea as constructor injection in larger apps and frameworks.
Common traps (advice that does not fix cycles)
These appear often in older guides. They solve other problems, not circular imports.
| Advice you may see | Reality |
|---|---|
| “Use absolute imports” | Absolute imports improve clarity. They do not remove a mutual dependency. |
“Set __all__” | Controls from module import * export surface only. No effect on cycles. |
“Always use importlib.import_module” | Dynamic import can delay loading, but if you still import at the wrong time, the cycle remains. Prefer an explicit local import or a restructure. |
| “Python enters an infinite loop” | Usual outcome is ImportError / partial init, not a spinning loop. |
| “Rename the file and hope” | Name clashes can cause confusing import errors, but renaming alone does not fix a real A↔B dependency. |
Package layout patterns that prevent cycles
Healthy packages look like a DAG (directed acyclic graph):
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 onlyPractical rules:
- Leaf modules import nothing from the app.
__init__.pyshould stay thin. Eagerfrom .a import */from .b import *chains are a common cycle factory.- Prefer explicit imports from stable leaf modules over “convenience” re-exports that pull half the package.
- Run as a package when developing libraries:
python -m package.apiavoids weird path hacks that mask real dependency issues. See also how to run Python scripts.
If you manage files a lot while refactoring, pathlib keeps move/rename scripts clearer than string path glue.
Debugging toolkit
Trace where the cycle enters
# debug_import.py
import sys
import trace
tracer = trace.Trace(count=False, trace=True)
tracer.runfunc(lambda: __import__("your_package.entry"))For everyday work, the traceback is enough. For large codebases, tools help:
python -X importtime -c "import your_package"— shows import order and cost- import-linter (third party) — encode “services must not import api” as CI rules
- pyright / mypy — catch TYPE_CHECKING mistakes early when using annotations
Catch partial-init symptoms in tests
def test_package_imports_cleanly():
import importlib
import your_package.api as api
importlib.reload(api) # optional stress
assert hasattr(api, "handle")If unit tests only import tiny modules in isolation, they can miss cycles that appear only through the app entrypoint. Add one smoke test that imports the real entry module.
Decision table: which fix should you use?
| Signal | Prefer |
|---|---|
| Shared model/constant used by both sides | Extract third module |
| One call path needs the other module | Lazy import in that function |
| Import exists only for type checkers | TYPE_CHECKING |
| Two services orchestrate each other | Dependency injection / callbacks / events |
| Cycles keep returning after patches | Package layering rules + linting in CI |
FAQ
Conclusion
Circular imports are a dependency-graph problem. Python surfaces them as partial initialization errors, especially the familiar “most likely due to a circular import” message. Fix them by making dependencies one-directional: extract shared leaves, lazy-import only where necessary, keep annotation imports behind TYPE_CHECKING, and wire collaborators from the outside when two services need each other.
Skip folklore fixes—absolute imports, __all__, and renaming files do not dissolve a real cycle. Once the graph is clean, imports become boring again, which is exactly what you want.
Related Guides
- Python type hints — annotation patterns that pair with
TYPE_CHECKING - Python pathlib — cleaner path handling while you reorganize packages
- How to run Python scripts — entrypoints (
python,-m) that affect import behavior - Python try/except — handling and diagnosing runtime import failures
- Python dataclasses — lightweight domain models that belong in leaf modules
- Python decorators — patterns that sometimes introduce import-time side effects