Back to Blog
Python

Python Circular Import: Causes and Fixes

python circular import: Understand why circular imports occur in Python, how the import system handles them, and practical fixes like local imports and TYPE_CHECKING.

circular importsimport systemmodule designtype hintsrefactoring
Illustration of two Python modules referencing each other, symbolizing a circular import.

A python circular import occurs when two or more modules depend on each other at module load time. The import system in Python executes a module's top-level code only once, and when it encounters an import that points back to a module that is still being initialized, it returns a partially initialized module object. This often leads to ImportError or AttributeError, and the failure can be confusing because the same code works when run in a different order.

What a Python Circular Import Is and Why It Happens

A circular import is a direct or indirect dependency cycle between modules. For example, module a imports module b, and module b imports module a at the top level. When Python starts importing a, it begins executing a's code. The first import b statement triggers the loading of b. While b is being loaded, it tries to import a. Since a is already in the import system's module cache but has not finished executing, Python returns the partially initialized a module object. Any attribute that has not yet been defined in a is missing, so accessing it raises AttributeError.

This behavior is not a bug in Python. It is a consequence of how the import system caches modules and executes top-level code. The problem is that the developer has created a dependency that cannot be resolved at import time.

How the Import System Detects a Circular Import

The import machinery in Python uses sys.modules as a cache. When a module is first imported, it is added to sys.modules before its code is executed. If the same module is imported again during that execution, Python finds it in sys.modules and returns the existing module object. The key detail is that the module object exists, but its attributes are not yet populated. So the second import does not raise an error by itself; the error appears when the code tries to access an attribute that has not been defined yet.

Consider this minimal example:

# a.py import b value = 10
# b.py import a print(a.value)

When a.py is imported first, it starts executing, imports b, and then b imports a. At that moment, a is in sys.modules but value has not been assigned yet. The print(a.value) line raises AttributeError: module 'a' has no attribute 'value'. If b.py is imported first, the opposite happens: a tries to access b's attributes before they are defined.

The error message can vary. Sometimes it is ImportError: cannot import name 'X' from partially initialized module 'Y', which appears when you use a from import. For example:

# a.py from b import some_function
# b.py from a import some_other_function

If a is imported first, it tries to import some_function from b. To do that, Python must load b, which then tries to import some_other_function from a. Since a is partially initialized, some_other_function is not yet defined, and Python raises ImportError. The message explicitly mentions "partially initialized module" to help you diagnose the cycle.

The Most Common Symptom: ImportError or AttributeError

In practice, you will encounter one of two errors. The first is ImportError with a message like cannot import name 'X' from partially initialized module 'Y'. This occurs with from module import name syntax. The second is AttributeError when you use import module and then access module.attribute.

Both errors happen only when the circular dependency is triggered during module loading. If the cycle involves only functions or classes that are called after all modules have finished loading, the problem may not surface immediately. That is why a circular import can remain hidden until a specific code path executes. For example, if a imports b and b defines a function that references a only inside its body, the import may succeed because the attribute access happens later. But if b accesses a at the top level, the error appears immediately.

Fix 1: Reorder Imports and Move Imports to the Bottom

One simple fix is to reorder the import statements so that the dependent module is fully initialized before the other module tries to access it. In the earlier example, if you import b after defining value in a, the cycle may resolve:

# a.py value = 10 import b
# b.py import a print(a.value)

Now when a is imported, it sets value before importing b. When b imports a, a already has value defined. This works, but it is fragile. The order depends on which module is imported first, and it can break if the entry point changes. Moving imports to the bottom of a module is a common workaround, but it hides the dependency cycle rather than removing it.

This approach is acceptable for small scripts or quick fixes, but it is not a maintainable solution for a growing codebase. The import order becomes an implicit contract that future developers must respect.

Fix 2: Import Inside Functions for Runtime-Only Dependencies

If the circular dependency is needed only inside a function or method, you can move the import into that function. This defers the import until the function is called, by which time all modules have finished loading.

# a.py def create_item(): from b import Item return Item()
# b.py from a import create_item

When b is imported, it imports create_item from a. a does not import b at the top level, so the cycle is broken. Later, when create_item is called, b is already in sys.modules and fully initialized, so the import succeeds.

This pattern is useful when the dependency is not needed at module load time. However, it has a small runtime cost: the import statement is executed every time the function is called. Python caches modules in sys.modules, so the actual lookup is fast, but the import statement still adds a tiny overhead. If the function is called frequently, this overhead may be noticeable in performance-critical code. In most applications, the cost is negligible.

Another consideration is that local imports can make the code harder to read because the dependencies are not visible at the top of the file. Use this fix sparingly and document why the import is local.

Fix 3: Use TYPE_CHECKING for Type Hints

A very common cause of circular imports is type hint annotations. When you annotate a function parameter with a class from another module, Python evaluates the annotation at definition time by default. If that other module imports the current module, you get a cycle. The solution is to use typing.TYPE_CHECKING to conditionally import the module only during static type checking, not at runtime.

# a.py from typing import TYPE_CHECKING if TYPE_CHECKING: from b import Item def process(item: "Item") -> None: pass
# b.py from a import process

TYPE_CHECKING is True only when a type checker like mypy or Pyright is analyzing the code. At runtime, it is False, so the import is not executed. The annotation is written as a string literal ("Item") to avoid evaluation at runtime. Alternatively, you can use from __future__ import annotations to make all annotations lazy strings, which also avoids the need for quotes in many cases.

# a.py from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: from b import Item def process(item: Item) -> None: pass

With from __future__ import annotations, annotations are stored as strings and not evaluated, so the import is not triggered. This is a clean solution for type-only dependencies. It keeps the code readable and avoids runtime import overhead.

Fix 4: Refactor Shared Code Into a New Module

The most robust fix is to break the cycle by moving the shared dependency into a third module. If a and b both need a common class or function, put that class or function in c, and have both a and b import from c. This eliminates the cycle entirely.

# c.py class Item: pass
# a.py from c import Item def process(item: Item) -> None: pass
# b.py from c import Item from a import process

Now a and b do not import each other. The dependency graph is acyclic. This is the preferred solution for long-term maintainability because it makes the module structure explicit and avoids hidden coupling.

Refactoring requires identifying the shared concept. It might be a base class, a utility function, a configuration object, or a data model. The cost is moving code and updating imports, but the benefit is a cleaner architecture that does not rely on import order or deferred imports.

Choosing the Right Fix for Your Codebase

The right fix depends on why the circular import exists. If the cycle is caused by type hints, use TYPE_CHECKING. If it is caused by a function that needs another module only at runtime, a local import is acceptable. If the cycle is structural, refactor into a new module.

CauseRecommended FixWhen to Avoid It
Type hint annotationsTYPE_CHECKING with string annotationsWhen the imported symbol is used at runtime
Runtime-only dependency inside a functionImport inside the functionWhen the function is called very frequently and overhead matters
Structural dependency between modulesRefactor shared code into a new moduleWhen the refactor would create an artificial module or add complexity
Simple ordering issueReorder importsWhen the import order is not guaranteed across entry points

Do not apply a fix mechanically. Analyze the dependency and choose the approach that keeps the code readable and maintainable. If you have multiple cycles, address the root cause rather than patching each symptom.

Maintainability and Runtime Cost of Each Approach

Each fix has tradeoffs. Reordering imports is the least maintainable because it relies on execution order. Local imports hide dependencies and add a tiny runtime cost per call, but they are simple and do not change the module structure. TYPE_CHECKING is clean for type-only dependencies, but it does not solve runtime circular imports. Refactoring is the most maintainable but requires more work.

In performance-sensitive code, local imports inside hot loops can add measurable overhead. If a function is called millions of times, the repeated import statement, even with sys.modules caching, may show up in profiling. In such cases, refactoring or using a module-level import with TYPE_CHECKING is better. For typical application code, the overhead is negligible.

Another maintainability concern is that local imports make it harder to see the full dependency graph of a module. Tools that analyze imports may miss them, and developers may not realize a module depends on another until runtime. This can lead to unexpected errors in production. Documenting why a local import exists helps, but a structural refactor is clearer.

Finally, remember that circular imports are a design smell. They often indicate that responsibilities are not cleanly separated. While the fixes above resolve the immediate error, consider whether the module boundaries should be redrawn. A small refactor now can prevent more complex dependency issues later.

When you encounter a python circular import, start by reproducing the error and identifying the exact cycle. Then apply the fix that matches the cause. For type hints, use TYPE_CHECKING. For runtime-only needs, use a local import. For structural cycles, refactor. This approach keeps your codebase healthy and avoids fragile import order dependencies.

python circular import: Practical Usage and Code Examples | RYUSLOG DEV