Python · intermediate
Python circular import: find the partially initialized module
Python's "partially initialized module" ImportError is raised when module A imports module B during its own initialization, and module B in turn imports (or re-imports) module A before A has finished defining its top-level names. The interpreter refuses to hand back a half-built module object and surfaces the ImportError naming the cyclic pair. Because Python's import system is deterministic and synchronous, the cycle is reproducible and traceable; the engineering task is locating the exact import statements that close the loop, deciding whether the cycle is structural (bad layering) or incidental (a top-level import that should live inside a function), and proving the fix by importing both modules from a clean interpreter.
The symptoms
- •An ImportError raised at interpreter start-up or first use, with a message of the form "cannot import name 'X' from partially initialized module 'M'" and "most likely due to a circular import".
- •The same import succeeds when run inside an already-imported parent package or when the second module is imported first, but fails when the cyclic module is imported first from a fresh interpreter.
- •The failing attribute name in the traceback corresponds to a symbol defined at module top level (a class, function, constant) rather than a name imported from a third party, indicating that the importer ran before the exporter's body finished.
- •Traceback shows two frames pointing at top-level `import` statements in different files inside the same package, with neither frame inside a function or class body.
- •Tests pass in isolation but fail when collected by a runner that imports test modules in a different order, because the test harness changes which module enters the import graph first.
Likely causes
- •Two sibling modules reference each other with top-level `import` or `from ... import ...` statements, so each one triggers loading of the other before its own class or function definitions have executed.
- •A module performs `from .helpers import util` at module top level for convenience, while `helpers.py` itself does `from .main import SomeClass` to type-annotate or to build a registry, creating a cycle that only manifests when the package is imported as a whole.
- •An `__init__.py` re-exports symbols from submodules and one of those submodules imports the package or a sibling that in turn re-imports from `__init__.py`, causing the package module to be only partially populated when the cycle closes.
- •A recently introduced refactor moved a function call, decorator, or `register()` call to module top level that previously lived inside an `if __name__ == "__main__":` guard, accidentally adding an import edge that did not exist before.
- •Type-checking-only imports were added (for example using `if TYPE_CHECKING:`) but the same names are also referenced at runtime outside that guard, so a real circular edge remains.
- •Dynamic import such as `importlib.import_module("pkg.b")` inside a module-level constant or decorator fires during initialization and reaches back into a module that has not finished loading.
First ten minutes
- 01Capture the full traceback including file paths and line numbers for both the failing `import` and the import that triggered it; do not rely on the message alone because the message names the symptom, not the cycle.
- 02From the traceback, extract the two file paths and the two line numbers involved in the import frames; these define the two endpoints of the suspected cycle.
- 03Open each of those two files and locate every top-level `import` and `from ... import` statement; cross-reference them to see whether file A imports anything from file B (directly or through a package) and vice versa.
- 04Determine which of the two modules is the "outer" one (the one whose import initiated the cycle) by checking which frame is deeper in the traceback's import chain; the outermost frame is the entry point.
- 05Confirm that the failing attribute is defined in the outer module at module top level (not inside a function) by reading the file; if it is defined inside a function, the diagnosis shifts to a lazy-load issue rather than a true circular import.
- 06Run a clean repro by starting a fresh interpreter (`python -c "import <package>"`) and by importing the inner module first (`python -c "import <package>.<inner>"`); record which ordering reproduces the error.
Evidence to collect
- •Evidence to collect while triaging a Python "partially initialized module" ImportError, focused on traceback frames, import edges, and reproducibility conditions.
- •The full traceback text with absolute file paths, line numbers, and the literal exception message including the "partially initialized module" wording.
- •The set of top-level import statements in each of the two suspect files, captured as `path:line` pairs so the cycle edges can be drawn.
- •The Python version (`python -V`) and whether the package uses regular `__init__.py` packages or PEP 420 namespace packages, because namespace packages change cycle semantics.
- •The order in which the modules were imported (entry point, test runner ordering, REPL vs script), since cycles only fail when the outer module enters the graph first.
- •The recent change history (git log or equivalent) for both files covering the period since the last known-good import, to identify the commit that introduced the cycle.
Where to look
- •Boundaries in the source tree and import machinery where circular import evidence will be visible.
- •The `__init__.py` file of the suspect package, because re-exports there are a common source of cycles between the package module and its submodules.
- •Top-level (unindented) `import` and `from ... import ...` statements in the two files named in the traceback; lines inside functions or `if __name__ == "__main__":` guards do not contribute to the cycle.
- •The boundary between a module's import block and its first top-level definition; an import that is executed before its target's class or function definitions is the one that closes the cycle.
- •Module-level decorators, `register()` calls, and `signal.connect()` style wiring, because these often run during initialization and implicitly pull in another module.
- •The test runner's module-discovery configuration (pytest collect order, unittest `TestLoader.sortTestMethods`), since reordering can mask or expose the cycle.
Diagnostic steps
- 01Ordered diagnostic steps that distinguish a true circular import from superficially similar ImportError causes.
- 02Reproduce the ImportError from a clean interpreter with `python -c "import <entry_module>"`; if the error disappears when running an existing script, the script may be importing the inner module first and hiding the cycle.
- 03From the traceback, identify the two `import` frames and read those exact lines; confirm that one frame's target is the other frame's module, which establishes a directed edge.
- 04Build the import graph for the two files by hand: list every top-level import in file A and file B, then check whether there is any path from A back to A that does not leave the package.
- 05Inspect the failing attribute in the outer module: verify it is a top-level binding (class, function, constant) and not something produced by a function call, because a `NameError` or `AttributeError` pattern changes the diagnosis.
- 06Distinguish a circular import from a missing dependency by temporarily commenting out the inner module's import of the outer one; if the original ImportError disappears, the cycle is confirmed and the commented line is the closing edge.
- 07Distinguish a circular import from a heavy import-time side effect (logging configuration, plugin registration) by checking whether the inner module imports the outer one only for its side effects; if so, the fix is to move that side effect behind a function boundary.
- 08If the package is a namespace package (no `__init__.py`), verify that no implicit `__init__.py` is present on the path; namespace packages change when partial modules are visible and can convert a benign cycle into a hard failure.
- 09Confirm the Python version with `python -V` and consult the modules tutorial section on intra-package references, because the recommended pattern for relative imports (`from . import x`) interacts with cycles differently than absolute imports.
Common mistakes
- •Misdiagnoses and remediation errors that engineers commonly make when addressing "partially initialized module" ImportErrors.
- •Treating the ImportError as a missing-dependency problem and "fixing" it by adding the inner module to requirements or to `sys.path`, which leaves the cycle intact and only shifts where it manifests.
- •Adding `if TYPE_CHECKING:` around the import but leaving a runtime reference to the same name outside that guard, so the cycle persists under a different code path.
- •Reordering imports alphabetically or aesthetically without verifying the dependency direction, which can hide a cycle behind a different ordering but does not remove it.
- •Splitting one logical cycle into two imports of the same name from different paths (absolute and relative) and assuming that counts as breaking the cycle; the import system still resolves both to the same module object.
- •Suppressing the symptom by wrapping the import in a broad `try: ... except ImportError:` block, which lets the partially initialized module propagate and produces a more confusing `AttributeError` later instead of fixing the cycle.
- •Moving the import inside a function and then immediately importing the same name at module top level for type hints, so the cycle is preserved under the type-hint import.
Safe fixes
- •Conditional fixes for circular imports, each paired with the evidence that justifies choosing it.
- •If the inner module only needs a name from the outer module for type annotations, move that import inside `if TYPE_CHECKING:` and confirm with `python -c "import <entry_module>"` that the ImportError no longer occurs.
- •If the inner module only needs the outer module's value inside a function, move the `from .outer import X` statement to the top of that function body and verify by importing both modules from a clean interpreter in either order.
- •If the cycle arises from the package's `__init__.py` re-exporting names that submodules also import, replace the submodule's `from <package> import X` with `from <package>.<submodule_with_X> import X` so the import edge no longer goes through the package module.
- •If two modules genuinely share a type or constant, extract that shared binding into a third module that depends on neither, and change both originals to import from the third module; verify by importing the third module first, then both originals.
- •If a module-level decorator or `register()` call is the closing edge, defer it by wrapping it in a function invoked from an explicit setup step rather than at import time, and confirm by running the original entry point.
- •If a dynamic `importlib.import_module` call closes the cycle during module initialization, replace it with a lazy lookup inside a function so the lookup happens after both modules have finished loading.
Prove the fix
- 01Observable regression checks that confirm a "partially initialized module" ImportError has been resolved and will not silently return.
- 02Start a fresh interpreter and run `python -c "import <entry_module>"`; the command exits with status 0 and produces no output, demonstrating that the outer module now loads without triggering the inner module's cycle.
- 03From the same fresh interpreter, run `python -c "import <package>.<inner_module>"`; this also exits cleanly, proving that the inner module can be imported first without depending on the outer module being fully initialized.
- 04Re-run the original failing script or test that surfaced the ImportError; it completes without raising ImportError, and any subsequent `AttributeError` that previously followed the suppressed ImportError is also absent.
- 05Inspect `sys.modules` after import by running `python -c "import <package>; import sys; print(sorted(k for k in sys.modules if k.startswith('<package>')))"` and confirm that every expected submodule is present, indicating that nothing was left partially loaded.
- 06Run the project's full test suite under its normal order; if a previously failing test now passes and no other test regresses, the cycle has been removed without introducing a new dependency direction.
- 07Re-check the two files' top-level import blocks: there should be no remaining directed cycle between them. A quick way to assert this is to confirm that file A's top-level imports no longer reach file B's top-level imports through any chain, which can be expressed as a static check in CI.
Prevention and next steps
- •Repository-level practices that keep "partially initialized module" ImportErrors from being reintroduced after a fix.
- •Adopt a convention that modules import only downward in the package hierarchy: a submodule may import from siblings or children of its package, but never from a parent module's `__init__.py` re-exports.
- •Keep `__init__.py` files thin: avoid re-exporting symbols that submodules also need to import, because every re-export is a potential cycle edge.
- •Place all runtime side effects (logging configuration, plugin registration, signal wiring) behind an explicit setup function rather than at module top level, so importing a module cannot trigger loading of another module that imports it back.
- •Add a static or import-time check in CI that walks top-level imports of each module and fails the build if a cycle is detected between two files in the same package.
- •Use `from __future__ import annotations` (PEP 563) where appropriate so that type-only references between modules can stay as strings and do not become runtime import edges.
Safe commands and checks
python -V
python -c "import <entry_module>"
python -c "import <package>.<inner_module>"
python -c "import <package>; import sys; print(sorted(k for k in sys.modules if k.startswith('<package>')))"
python -m py_compile <path_to_file_a> <path_to_file_b>
python -c "import ast, sys; tree=ast.parse(open('<path_to_file_a>').read()); print([n for n in tree.body if isinstance(n,(ast.Import,ast.ImportFrom))])"