How does moving an import under if TYPE_CHECKING: break an import cycle?
answer
- Half-built module in sys.modules
- One direction is not a real dependency
- Delete the runtime edge, keep the analysis edge
- Hint must stay unevaluated
- Guard the typing-only import
basics
~20 sThe cycle exists only because both modules import each other while running. If one direction is needed purely for annotations, guarding it with TYPE_CHECKING deletes that runtime edge, and the checker still sees both modules, so the hints keep resolving.
solid answer
~50 sA cycle bites because the second module to be imported reaches back into a half-initialised first one and finds the name it wants missing. When one of the two directions is needed only to write a type hint, that edge is not a real dependency: put it under `if TYPE_CHECKING:` and the interpreter never traverses it, while the checker still resolves both sides. The hint must then be one the interpreter does not evaluate — on 3.14 annotations are lazy by default (PEP 649), and on older runtimes you quote the hint or add `from __future__ import annotations`. This only works if the dependency really is typing-only. If the module also calls into the other at runtime, the cycle is genuine and the answer is structural: extract the shared piece, invert the dependency, or import inside the function.
code
python · 14 linesimport sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from fractions import Fraction
class Ledger:
def attach(self, share: "Fraction") -> None:
self.share = share
print("fractions" in sys.modules)
print(Ledger.attach.__qualname__)go deeper
Know the shape: an import needed only for a hint goes under the guard, and the module then imports without a circular-import error. Recognise the pattern when you see it in a codebase.
Explain the mechanics — a half-initialised module in sys.modules, the typing-only edge being removed, the checker still analysing the guarded block — and state what keeps the annotation from being evaluated on the runtimes you support.
Judge whether the cycle is false or genuine before reaching for the guard, and name the structural fixes when it is genuine. Be ready to say what breaks in a service that inspects annotations after the import is hidden.
Treat recurring cycles as a layering signal rather than a per-file annoyance: decide where shared abstractions live, whether modules may depend on concrete classes at all, and how import structure is enforced in review or tooling.
### What actually goes wrong in a cycle Importing a module runs it top to bottom, and the partially built module object is registered in `sys.modules` before that run finishes. Say a payment reconciliation job has `ledger.py` and `reconciler.py`, and each has a module-level import of the other. Whichever is imported first begins executing, hits its import of the second, and the second begins executing; when the second reaches its import of the first, the first is already in `sys.modules` but only half built. `import ledger` succeeds and gives back a module missing most of its names; `from ledger import Ledger` fails outright with an `ImportError` whose message mentions a partially initialised module. The failure therefore depends on import order and on which import form you used, which is why cycles feel intermittent. ### The typing-only edge is not a real edge Cycles that appear during typing adoption usually have an asymmetry. `reconciler.py` genuinely constructs and calls a `Ledger`. `ledger.py` does not use the reconciler at all — it only wants to name the class in a signature. That second direction is a documentation dependency masquerading as a code dependency, and it is the one to cut: ```python # ledger.py from typing import TYPE_CHECKING if TYPE_CHECKING: from reconciler import Reconciler class Ledger: def attach(self, reconciler: "Reconciler") -> None: self.reconciler = reconciler ``` At runtime `ledger` no longer imports `reconciler`, so the graph is acyclic and import order stops mattering. The checker still analyses the guarded block, so `Reconciler` resolves and `attach` keeps its real signature. Nothing is weakened for analysis; only the runtime edge disappears. ### Keeping the annotation unevaluated The guarded name is not bound at runtime, so the annotation must not be evaluated when the class body executes. From 3.14, PEP 649 makes annotations lazily evaluated by default: they are compiled into a separate function that runs only if something asks for them, so the plain unquoted form is safe on import. On 3.13 and earlier the annotation was evaluated at definition time unless the module carried `from __future__ import annotations` (3.7, PEP 563) or the hint was written as a string. Libraries supporting older floors still add the future import for exactly this reason. What has never been lazy is everything outside annotation position. If `ledger.py` wanted `class Ledger(Reconciler)`, or applied a decorator from the other module, or wrote `Alias = Reconciler` at module level, or called `cast(Reconciler, x)` with the class unquoted, the name is evaluated as the module loads and you get a `NameError` instead of a cycle. Trading one import-time crash for another is not progress; those cases mean the dependency is real. ### When the cycle is genuine If both modules call into each other at runtime, hiding an import removes an edge nobody was traversing and fixes nothing. Three honest fixes exist. Extract the shared piece into a third module both import, which usually reveals that the cycle was a missing abstraction. Invert one direction behind a small interface, so the lower-level module depends on a protocol rather than on the concrete class. Or move the import inside the function that needs it, so it executes after both modules have finished initialising — cheap, since a repeat import is a `sys.modules` lookup, but it hides the dependency from readers and from anything that scans the import graph, so use it sparingly. ### What still breaks after the fix The module now imports cleanly, but the guarded name remains absent at runtime. Anything that resolves the annotation later — a framework that inspects a signature to build validation or injection, a serializer that reads hints — searches the module namespace and does not find the class. That failure appears wherever the inspection happens, not at import, so it can survive into a running service. If a module must support hint resolution, the class has to be reachable at runtime, and the cycle has to be fixed structurally instead. ### How to talk about it in an interview Name the mechanism, not the ritual: half-initialised modules in `sys.modules`, a typing-only edge that is not a dependency, a checker that reads the branch the interpreter skips, and lazy annotations that keep the hint from being evaluated. Then say the limit out loud — the guard is a fix for false edges, not a cure for tangled design.
- What if the two modules genuinely need each other while running, not just for hints?Then the guard cannot help — it removes an edge nobody was traversing. Fix it structurally: extract the shared piece into a third module both import, invert one direction behind a small interface so the lower-level module depends on a protocol rather than a concrete class, or move the import inside the function that needs it so it runs after both modules have initialised. The last one works but hides the dependency, so treat it as a local escape hatch.
- You cut the runtime edge and the module imports cleanly. What can still fail later?Anything that resolves the hint while the program runs. A framework that reads a function signature to build validation or injection will look up a class the module never imported and fail at inspection time, far from the import. If a module has to support that kind of introspection, the name must be genuinely importable and the cycle has to be broken by restructuring instead.
- Why does the same cycle sometimes fail and sometimes not?Because the outcome depends on which module is imported first and on the import form. `import mod` succeeds against a half-initialised module and only fails later when you touch a missing attribute; `from mod import Name` fails immediately. Change the entry point, or the order of imports in a package `__init__`, and a latent cycle starts or stops raising.
Two departments both list each other on an org chart, but only one ever sends work across. Erasing the decorative arrow untangles the chart without changing how anything operates.
saying these in an interview costs you the question
- Claims the guard fixes any circular import
- Leaves a real runtime call to the guarded module in place
- Thinks the checker also stops seeing the guarded module
- Forgets older runtimes need a quoted or future-stringified hint
- Guards an import still used as a base class
- Says function-local imports are always the better fix