Fix Python Circular Imports by Separating Module Responsibilities

Quick answer: A circular import is an import-graph cycle: module A imports B while B imports A before either module has finished initialization. Move shared contracts downward and make dependencies flow in one direction.

Python circular import infographic showing module dependency cycle, neutral shared module, and safer import direction
A circular import is a dependency-graph problem; move shared contracts to a neutral module instead of hiding the cycle.

A circular import happens when two or more Python modules import each other while Python is still loading them. The result is often an import error that mentions a partially initialized module.

The main references are Python’s import system reference, the modules tutorial, and the TYPE_CHECKING documentation.

The best fix is usually structural: move shared code into a lower-level module, reduce top-level side effects, or make imports point in one direction.

Delayed imports can help in specific cases, but they should not be the default fix for a tangled design.

See A Circular Import

This layout shows the problem: a.py imports b.py, while b.py imports a.py.

# a.py
from b import B

class A:
    def create_b(self):
        return B()

# b.py
from a import A

class B:
    def create_a(self):
        return A()

When Python loads a.py, it starts loading b.py. Then b.py asks for A before a.py has finished defining it.

That is why the error often says a module is partially initialized.

Do not focus only on the final line of the error. The important clue is the chain of imports that led back to a module that was not done loading.

Move Shared Code To A Third Module

If both modules need the same class, constant, or helper, move that shared code into a module that neither side needs to import back from.

# shared.py
class Config:
    def __init__(self, name):
        self.name = name

# a.py
from shared import Config

def build_config():
    return Config("app")

Now a.py imports shared.py, but shared.py does not import a.py.

This one-direction dependency is easier to reason about and test.

This is the most durable fix because it removes the cycle instead of hiding it.

Python Pool infographic showing Python modules, imports, edges, and a circular dependency
Module graph: Python modules, imports, edges, and a circular dependency.

Move Imports Inside A Function

A local import can delay the import until the function is called.

# a.py
class A:
    def create_b(self):
        from b import B

        return B()

# b.py
class B:
    def name(self):
        return "B"

This can break a cycle when the dependency is only needed inside one method.

Use this carefully. If many functions need local imports, the module design probably needs a structural cleanup.

A local import is acceptable for optional behavior, plugin loading, or a narrow dependency that is not needed during normal module import.

Use TYPE_CHECKING For Type Hints

Sometimes the circular import exists only because type hints import classes at runtime.

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from users import User

class Order:
    def assign(self, user: "User"):
        self.user_id = user.id

TYPE_CHECKING is false at runtime and true for type checkers.

String annotations let the runtime avoid importing the hinted class immediately.

Python Pool infographic mapping module A to B and B back to A during partial initialization
Import cycle: Module A to B and B back to A during partial initialization.

Avoid Top-Level Work

Top-level code runs during import. Keep heavy work, object creation, and cross-module calls inside functions.

# app.py
from services import create_user

def main():
    user = create_user("Ada")
    print(user.name)

if __name__ == "__main__":
    main()

This keeps imports lightweight and makes execution start from an explicit entry point.

It also makes tests easier because importing a module does not immediately run application work.

Command-line scripts should usually place startup work in main(). Library modules should define reusable objects without launching work during import.

Follow The Traceback

The traceback usually shows the import loop. Read it from the first import through the repeated module names.

For example, if a.py imports b.py and b.py imports a.py, the traceback will usually show both module names before the final failure.

Write down the modules in the order they import each other. Then choose which dependency should move down to a shared module or move inside a function.

The practical rule is to keep dependency direction simple. Low-level modules should not import high-level application modules, and modules should avoid doing real work at import time.

If the cycle involves more than two modules, draw the chain. The best fix is usually the first shared concept that can move to a lower-level module.

Python Pool infographic comparing shared module, dependency inversion, local import, and clean graph
Separate responsibilities: Shared module, dependency inversion, local import, and clean graph.

Prefer Clear Module Boundaries

A circular import is often a design signal. Two modules may be sharing too many responsibilities or each may know too much about the other.

# Better dependency direction
# models.py defines data structures
# repositories.py imports models
# services.py imports repositories
# app.py imports services

from services import run

run()

Layered imports make circular dependencies less likely because each module points to a lower or neighboring layer, not back up to the caller.

If you need a quick unblock, a local import may be acceptable. If the cycle keeps returning, refactor the shared behavior into a dedicated module.

Also watch for cycles created by type hints, constants, and test helpers. Those imports can look harmless but still run at module load time.

Once the dependency direction is clear, circular import errors usually disappear without special import tricks.

Prefer simple import graphs.

Recognize The Cycle

The error often mentions a partially initialized module or a missing name that exists later in the source. Trace imports at module scope, including imports hidden in package __init__.py files. The problem is the initialization order, not necessarily a missing installation.

# a.py
from b import make_value

# b.py
from a import CONFIG  # cycle during initialization
Python Pool infographic testing package boundaries, side effects, order, and validation
Import checks: Package boundaries, side effects, order, and validation.

Move Shared Code To A Neutral Module

Constants, dataclasses, protocols, and small helpers used by both modules belong in a lower-level module that imports neither feature module. This breaks the graph and makes ownership clearer.

# shared.py
CONFIG = {"mode": "safe"}

# a.py and b.py can both import shared
from shared import CONFIG
print(CONFIG["mode"])

Use Local Imports With A Reason

A function-local import defers the dependency until the function runs and can break an initialization-time cycle. It is appropriate for an optional or late-bound feature, but it should not conceal a design dependency that will remain confusing. Prefer a module redesign when possible.

def build_report(data):
    from reports.formatting import format_report
    return format_report(data)

print(build_report({"ok": True}))

Python’s import-system reference explains module creation, execution, and partially populated module objects.

For import-graph cleanup, compare optional imports, subdirectory imports, and importing classes.

Frequently Asked Questions

What causes a circular import in Python?

Two modules depend on each other during initialization, so one tries to access names in the other before that module is fully populated.

How do I fix a circular import?

Move shared constants, types, or helpers into a lower-level module and make dependencies flow in one direction.

Can a local import fix a circular import?

A local import can defer loading, but it should be a deliberate boundary; it may hide a design cycle rather than remove it.

Why does partially initialized module appear?

Python has created the module object but has not finished executing its top-level code when the cycle requests it again.

Subscribe
Notify of
guest
1 Comment
Oldest
Newest Most Voted
azzam49
azzam49
4 years ago

Thanks for useful post