A circular import is not a special alternate import mode. It is ordinary import encountering a module object whose top-level code has started but not finished. Python places that object in sys.modules before execution precisely so cycles terminate. The second edge receives the same object, with only the names assigned so far.
That means a cycle is not automatically an error. It becomes observable when code reads an absent name, invokes initialization too early, subclasses an unavailable class, or relies on side effects whose order the graph does not guarantee. Reordering statements can move the failure without making the architecture stable.
The previous series article established module caching and early insertion. Here we focus on timelines and repairs: lowering shared concepts, inverting dependencies, deferring one edge, separating type-only dependencies, and testing in a fresh interpreter.
Version boundary. Early insertion in
sys.modules, execution of module code, and import statement binding are Python import semantics. CPython 3.14's wording for partially initialized modules, deadlock detection, import locks, and timing are implementation details. Experiments use temporary unique package names and were run on CPython 3.14.7.
Experiment 1: draw the namespace at failure
Two modules expose their current keys before the premature access.
import importlib
import sys
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "cycle_a.py").write_text(
"import cycle_b\na_ready = True\n", encoding="utf-8"
)
(root / "cycle_b.py").write_text(
"import cycle_a\n"
"print('a_ready' in vars(cycle_a))\n"
"seen = cycle_a.a_ready\n",
encoding="utf-8",
)
sys.path.insert(0, directory)
try:
try:
importlib.import_module("cycle_a")
except AttributeError as error:
print(type(error).__name__)
print("cycle_a" in sys.modules, "cycle_b" in sys.modules)
finally:
sys.path.remove(directory)
sys.modules.pop("cycle_a", None)
sys.modules.pop("cycle_b", None)
The membership test prints False: cycle_a exists, but execution has not reached a_ready. The failed imports are then removed from sys.modules. Exact exception text is diagnostic assistance, not a portable API.
The timeline is the real debugger:
- Start
cycle_a; cache its module. cycle_arequestscycle_b; cache and start it.cycle_brequestscycle_a; receive the cached incomplete object.- Read
cycle_a.a_readybefore assignment. - Propagate failure and clean failing cache entries.
Experiment 2: some cycles complete
If neither side reads the other's late names during import, both bodies can finish.
import importlib
import sys
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "calm_a.py").write_text(
"import calm_b\ndef from_a(): return 'A' + calm_b.from_b()\n",
encoding="utf-8",
)
(root / "calm_b.py").write_text(
"import calm_a\ndef from_b(): return 'B'\n",
encoding="utf-8",
)
sys.path.insert(0, directory)
try:
module = importlib.import_module("calm_a")
print(module.from_a())
finally:
sys.path.remove(directory)
sys.modules.pop("calm_a", None)
sys.modules.pop("calm_b", None)
This prints AB. Function bodies defer attribute access until calls made after initialization. The cycle is behaviorally valid, but still creates coupling: importing either module initializes both, isolated tests become harder, and a future decorator or module-level registry can make the cycle fail.
Treat a harmless cycle as a design smell to assess, not an emergency to rewrite. Stable mutually recursive domain concepts may justify it. Accidental layer reversal usually does not.
Experiment 3: import form changes the failure surface
from module import name demands the attribute immediately; import module binds the module object.
import importlib
import sys
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "form_a.py").write_text(
"from form_b import b_value\na_value = 'A'\n", encoding="utf-8"
)
(root / "form_b.py").write_text(
"from form_a import a_value\nb_value = 'B'\n", encoding="utf-8"
)
sys.path.insert(0, directory)
try:
try:
importlib.import_module("form_a")
except ImportError as error:
print(type(error).__name__)
finally:
sys.path.remove(directory)
sys.modules.pop("form_a", None)
sys.modules.pop("form_b", None)
Changing both statements to module imports may defer attribute reads and let initialization complete. That can be a sound repair when runtime use is naturally later. It is not proof the dependency direction is good, and it changes local API spelling. Choose it because delayed lookup matches ownership, not because it silences one traceback.
Experiment 4: lower shared concepts
The most durable repair often introduces a lower-level module that depends on neither consumer.
import importlib
import sys
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "shared_model.py").write_text(
"class Record:\n pass\n", encoding="utf-8"
)
(root / "reader_layer.py").write_text(
"from shared_model import Record\ndef read(): return Record()\n",
encoding="utf-8",
)
(root / "writer_layer.py").write_text(
"from shared_model import Record\ndef accepts(value): return isinstance(value, Record)\n",
encoding="utf-8",
)
sys.path.insert(0, directory)
try:
reader = importlib.import_module("reader_layer")
writer = importlib.import_module("writer_layer")
print(writer.accepts(reader.read()))
finally:
sys.path.remove(directory)
for name in ("reader_layer", "writer_layer", "shared_model"):
sys.modules.pop(name, None)
Both higher layers depend downward on one model. This preserves class identity and makes ownership explicit. Do not create a junk-drawer common.py; name the lower module after the cohesive concept it owns.
Experiment 5: invert behavior through registration
When a low-level module needs optional high-level behavior, let the high level register it.
callbacks = []
def register(callback):
callbacks.append(callback)
def dispatch(value):
return [callback(value) for callback in callbacks]
def format_upper(value):
return value.upper()
register(format_upper)
print("[result] registered callback output:", dispatch("ready"))
In a package, the registry belongs in the lower-level module, while an application assembly point imports implementations and registers them. The lower layer no longer imports concrete high-level plugins. This is dependency inversion in small form.
Registration should be explicit and idempotent where reloads or tests are possible. Avoid decorators that mutate global registries during arbitrary imports unless import-time discovery is an intentional, tested contract.
Experiment 6: defer a narrow import
A function-local import delays one graph edge until the operation runs.
import importlib
import sys
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "lazy_a.py").write_text(
"def result():\n from lazy_b import value\n return value\n",
encoding="utf-8",
)
(root / "lazy_b.py").write_text(
"import lazy_a\nvalue = 42\n", encoding="utf-8"
)
sys.path.insert(0, directory)
try:
module = importlib.import_module("lazy_a")
print(module.result())
finally:
sys.path.remove(directory)
sys.modules.pop("lazy_a", None)
sys.modules.pop("lazy_b", None)
The import succeeds because lazy_b is first requested after lazy_a has finished. Repeated calls normally hit sys.modules, so they do not repeatedly execute the file, though statement and lookup overhead remains.
Use this for optional dependencies, expensive modules needed on rare paths, or a genuinely runtime-only edge. Add a comment when the placement prevents a cycle. If dozens of functions require local imports, redraw the architecture instead of distributing graph knowledge everywhere.
Experiment 7: type-only imports need not run
Annotations can create cycles that runtime behavior does not need. TYPE_CHECKING separates static analysis from execution.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
def first(values: "Sequence[int]") -> int:
return values[0]
print("[check] runtime TYPE_CHECKING value:", TYPE_CHECKING)
print("[result] first sequence value:", first([42]))
print("[state] stored annotations:", first.__annotations__)
At runtime TYPE_CHECKING is False; static tools treat the guarded import as available. The quoted annotation avoids evaluating Sequence when the function is defined. Annotation semantics have evolved across Python versions and frameworks may call typing.get_type_hints(), which then needs names resolvable in the appropriate namespaces.
Use this only for dependencies needed by type analysis. If runtime validation, serialization, or introspection resolves annotations, move shared types to a lower module or provide the namespace deliberately. A guard must not disguise a real runtime dependency.
Experiment 8: verify first import in a subprocess
Import state is process-global enough that a subprocess is the cleanest integration boundary.
import subprocess
import sys
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "entry.py").write_text("import dependency\nprint('ready')\n", encoding="utf-8")
(root / "dependency.py").write_text("value = 42\n", encoding="utf-8")
process = subprocess.run(
[sys.executable, "-c", "import entry"],
cwd=directory,
text=True,
capture_output=True,
check=False,
)
print(process.returncode, process.stdout.strip())
This prints 0 ready. A subprocess avoids stale sys.modules entries, imported parent packages, escaped class references, and test-order dependence. Use it for package initialization, optional dependency, warning, and circular-import integration tests. Unit tests can still exercise lower-level factories directly.
Diagnose the edge, not the last line
When a cycle fails, record fully qualified module names and top-level statements in execution order. Find the first attribute demanded before assignment. Then ask why that edge exists:
- Shared data model: move it below both consumers.
- High-level callback needed by a low level: register or inject it.
- Runtime-only operation: defer the narrow import.
- Type-only relation: guard it and use appropriate annotation strategy.
- Package facade importing every child: reduce eager re-exports.
- Import-time side effect: move it to explicit application startup.
Large frameworks often expose cycles through package __init__.py files. Importing package.feature executes package/__init__.py first. An eager facade that imports all submodules can add hidden edges before the requested child starts. Keep package initializers cheap and deliberate.
Concurrency does not make order sensitivity safe. Python implementations coordinate imports to avoid duplicate execution, and CPython has per-module locks and deadlock handling, but application threads should not depend on racing first imports. Import and assemble the application before serving concurrent work.
Package facades and re-exports
Public packages often re-export convenient names from __init__.py. That facade is useful, but every eager re-export adds an initialization edge. Internal modules importing from the public facade can then loop back through code that is importing them. A stable rule is for internals to import from the defining sibling module, while external consumers use the facade.
For example, from package import Model inside package.service may require package.__init__ to finish exporting Model; from package.model import Model names the owner directly. This is not merely a syntactic workaround. It records dependency direction and avoids making internal construction depend on public presentation.
Module-level __getattr__, standardized by PEP 562, can implement lazy facade attributes. It can reduce eager edges, but adds hidden runtime import behavior and complicates static tooling. Use it for a deliberate compatibility or startup requirement, not to conceal a tangled graph. Explicit submodule imports are easier to trace.
Framework startup phases
Frameworks often discover models, routes, handlers, or settings during imports, magnifying cycles. Separate phases: import definitions, construct an application registry, validate it, then start resources. A module that registers itself as an incidental import side effect combines all four and makes tests depend on collection order.
An application factory is a useful assembly boundary. It can import concrete adapters after low-level interfaces are defined, register them explicitly, and return a configured object. Tests can construct smaller graphs without deleting modules. Command-line entry points and process workers can call the same factory, making startup order reviewable.
Dependency injection does not require a large container. Passing a callable into a constructor or exposing a register() function is enough to invert one edge. Introduce only the mechanism needed by the graph; replacing a two-module cycle with a reflection-heavy container can make ownership less visible.
Detecting cycles before runtime
Static import graph tools can identify strongly connected components, but results require interpretation. Imports under platform guards, TYPE_CHECKING, or functions may not execute during startup. Dynamic imports assembled from strings may be invisible. Use graph output to find review targets, then verify actual entry points in fresh subprocesses.
Architecture tests can enforce broad layer rules, such as domain modules never importing web adapters. Those rules prevent classes of cycles and communicate ownership better than a denylist of individual edges. Keep exceptions rare and documented; a growing allowlist indicates that the layer model does not match the code.
Practical decisions
- Draw dependencies with fully qualified module names, including package initializers.
- Treat exact "partially initialized" text as a clue, not an interface.
- Prefer lowering shared concepts or inverting dependencies over moving lines.
- Use local imports only for a narrow, explainable runtime edge.
- Separate static typing dependencies only when runtime resolution is unnecessary.
- Keep module top levels limited to definitions and cheap deterministic assembly.
- Test first-import behavior in subprocesses and vary supported entry points.
- Restart after structural fixes; manually deleting cache entries can leave duplicate module worlds.
Exercises
- Add prints around every statement in Experiment 1 and draw each namespace after every event.
- Convert the harmless cycle to
fromimports and identify the first premature demand. - Refactor a three-module cycle by extracting one cohesive lower-level model; explain why its ownership is correct.
- Replace a low-level import of a formatter with an injected callable and test registration order explicitly.
- Build a package whose eager
__init__.pycreates a cycle, then repair the facade without changing child behavior. - Run the same circular pair from two subprocess entry points and verify both initialization orders.
Keep this model
A circular import exposes an ordinary module during its initialization window. The object is real and cached; its namespace is incomplete. Failures happen at an order-sensitive edge that asks for something not assigned yet. A cycle that happens to complete can still be fragile when future top-level work adds an earlier demand.
Repair ownership and direction first. Lower shared concepts, invert high-level behavior, or defer only the edge that is truly runtime-only. The goal is not merely a green import; it is a dependency graph whose initialization order no longer carries hidden meaning.