Skip to content

← Orientation and the Gate step 13 of 13

Medium Primitives

Placement 6/6: import-cycle detection

Placement diagnostic, 6 of 6. About seven minutes. If this one is opaque, T12 (Seams, Modules, Packaging and Tooling) is where you start.


An import cycle is not a style problem. It is a load-order problem: at the moment models imports core, core is a half-initialised module object whose class definitions may not exist yet, so you get ImportError: cannot import name 'X' from partially initialized module — and, worse, you get it only in the order the first importer happens to run, so it reproduces in production and not in your test suite.

A module’s imports are a directed graph, so “does this package have a cycle” is a graph question with a machine answer. That is exactly what tools like import-linter enforce in CI, and building the check yourself is the fastest way to understand what they are asserting.

def solve(imports: dict[str, list[str]]) -> frozenset[str]:

imports maps a module name to the modules it imports directly. Return the frozenset of modules that lie on at least one cycle. Specifically:

  • a module is on a cycle iff it can reach itself by following one or more edges;
  • a self-import ({"a": ["a"]}) counts;
  • names that appear only as import targets and never as keys are third-party or stdlib modules — they have no outgoing edges recorded, so they can never be on a cycle;
  • a module that merely imports into a cycle without being reachable from it is not on the cycle. In {"a": ["b"], "b": ["c"], "c": ["a"], "d": ["a"]} the answer is {"a", "b", "c"}, not {"a", "b", "c", "d"}.

Return a frozenset, and mean it

Returning a list here fails, and the failure is deliberate. This answer has no meaningful order — any order you pick is an implementation detail of your traversal, and the first person to switch a set for a dict in your code will change it. A frozenset says “unordered, and you may not mutate my answer” in the signature, so a caller cannot rely on either. Under the strict gate, frozenset on its own is also not enough: with --disallow-any-generics you must write frozenset[str].

Production consequence

The cycles that hurt are rarely two modules long. They are eight modules long, they pass through a utils that everybody imports, and they appear when someone adds one convenience import to a __init__.py. That is why the contract worth enforcing in CI is not “no cycles between these two files” but a layering one: “nothing in domain may import anything in adapters“, checked as a reachability query over exactly this graph. If you can write this function, you can write that contract.

A cycle you cannot remove has three standard exits, in descending order of honesty: extract the shared piece into a third module that both import; move one import under if TYPE_CHECKING: when it exists only for annotations; or defer it into the function body that needs it. Note that PEP 810’s lazy imports, landing in 3.15, do not fix cycles in the general case — the cycle is still there, you have only moved when it detonates.