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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. 01Ordered diagnostic steps that distinguish a true circular import from superficially similar ImportError causes.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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

  1. 01Observable regression checks that confirm a "partially initialized module" ImportError has been resolved and will not silently return.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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))])"