Skip to content
Topics
Python
Python Circular Import: How to Fix It (With Working Examples)

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)

SituationWhat to do first
Two modules import each other at top levelMove shared code into a third module both can import
You only need a name inside one functionImport inside that function (lazy import)
You only need types for annotationsUse from typing import TYPE_CHECKING and quote annotations if needed
Large package with deep cyclesRedesign 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.

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.py

Typical 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:

  1. It creates an empty module object and puts it in sys.modules.
  2. It runs the module body top to bottom.
  3. 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.

  1. Reproduce with a clean entrypoint
    Run the same file Google/search errors mention (python -m package.module or the script path). Circular imports are entrypoint-sensitive.

  2. Read the full traceback bottom-up
    The last few frames usually show module_x importing module_y while module_y is already on the stack.

  3. 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.

  4. Classify the need

    • Runtime value/function → restructure or lazy import
    • Type only → TYPE_CHECKING
    • Shared constants/models → extract to a leaf module
  5. Apply the smallest correct fix
    Prefer extract shared module > lazy import > redesign package layout. Avoid cargo-cult “absolute imports will fix it.”

  6. Re-run from the same entrypoint
    Confirm both python a.py style and package-style -m entrypoints 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 seeReality
“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_moduleDynamic 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 only

Practical rules:

  1. Leaf modules import nothing from the app.
  2. __init__.py should stay thin. Eager from .a import * / from .b import * chains are a common cycle factory.
  3. Prefer explicit imports from stable leaf modules over “convenience” re-exports that pull half the package.
  4. Run as a package when developing libraries: python -m package.api avoids 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?

SignalPrefer
Shared model/constant used by both sidesExtract third module
One call path needs the other moduleLazy import in that function
Import exists only for type checkersTYPE_CHECKING
Two services orchestrate each otherDependency injection / callbacks / events
Cycles keep returning after patchesPackage 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