skip to content

A circular import between `routing.planner` and `routing.solver` fails only when the solver is imported first — why is a cycle order-dependent, and how do you find its entry point?

level: seniorimportance: should knowfreq 33%

answer

  1. The cycle is not symmetric
  2. First one in gets caught half-built
  3. Only names bound above the import are visible
  4. One entry point hides the other order
  5. Import each module in a fresh process

basics

~20 s

Whichever module is imported first is the one left half-built, so the cycle only breaks when the name the other module needs sits below the line that starts it. Reproduce each entry order in a fresh process.

solid answer

~50 s

A cycle is not symmetric. The module imported **first** is the one left partially initialized, and the module running **second** is the one that raises — so swapping the entry point swaps which names must already exist. If `routing.planner` binds what the solver needs above its own `from routing.solver import ...` line, entering through the planner works, while entering through the solver reaches that line too early and fails. That is why a route-optimisation service that imported cleanly for months breaks the day a worker or a test imports the submodule directly. To diagnose: reproduce each order with `python -c "import routing.solver"` in a clean process, read the traceback top-down as an import chain, and use `python -X importtime` for the whole tree. The durable fix is lifting the shared name into a module both import, so no order can fail.

code

python · 12 lines
python
import sys, tempfile, pathlib, subprocess

d = pathlib.Path(tempfile.mkdtemp())
(d / "planner.py").write_text("LABEL = 'planner'\nfrom solver import solve\n")
(d / "solver.py").write_text("from planner import LABEL\ndef solve():\n    return LABEL\n")

for first in ("planner", "solver"):
    proc = subprocess.run(
        [sys.executable, "-c", f"import {first}"],
        env={"PYTHONPATH": str(d)}, capture_output=True, text=True,
    )
    print(first, "->", "ok" if proc.returncode == 0 else proc.stderr.strip().splitlines()[-1])

go deeper

for a junior

Take away the headline: the same two files can import fine one way round and fail the other, so 'it works on my machine' is not evidence. Try importing each module on its own to check.

for a middle

Explain the asymmetry — the first module imported is the half-built one, and only the names it bound above the cycle line are available — and reproduce both orders in separate processes to confirm it.

for a senior

Demonstrate the diagnosis: read the traceback as an import chain, use python -X importtime for the tree, know that retrying cannot work because failed modules are evicted from the cache, and fix by extracting the shared name rather than reordering.

for a principal

Own the prevention story — an import test that loads every public module in isolation, plus a clear position on dependency direction, so cycles fail at the commit that adds them rather than in a new worker months later.

### Why order decides the outcome Run the cycle twice, from each end, and watch the cache. Entering through `routing.planner`: the planner object is cached empty, its body runs, binds `LABEL`, then reaches `from routing.solver import solve`. The solver body runs and does `from routing.planner import LABEL` — the planner is half-built, but `LABEL` was bound *before* the cycle started, so the read succeeds. The solver finishes, the planner finishes, everything works. Entering through `routing.solver`: the solver object is cached empty and its very first line is `from routing.planner import LABEL`. The planner body now runs, binds `LABEL`, and reaches `from routing.solver import solve` — but the solver is the half-built one this time and is still parked on line 1, so `solve` does not exist. `ImportError`. Same two files, same cycle, opposite outcome. The rule underneath: **the first module in is the one that gets caught half-built**, and only the names it bound above the cycle-triggering import are visible to the rest of the cycle. ### Why this shows up late and in odd places A long-lived service usually has one entry point, so one order. That order is the only one exercised, and the latent cycle is invisible. It surfaces when something imports a submodule directly: * a batch worker for a route-optimisation job that imports `routing.solver` rather than starting the app; * a unit test that imports the module under test in isolation; * a management or CLI subcommand; * a process started with the spawn or forkserver start method, which re-imports modules in the child rather than inheriting them. A package `__init__.py` that imports both submodules in a fixed order effectively pins the good order for anyone importing the package — and hides the problem from exactly the entry points that will not go through it. Deferred imports make the timing stranger still. If the cycle has been "fixed" by pushing imports into functions, nothing fails at start-up at all: the process comes up, reports healthy after a 45-second cold start, and only raises when a request reaches the code path that runs the deferred import. A failure that used to be a crash loop is now a runtime error on one endpoint. ### Diagnosing it 1. **Reproduce the order in a clean process.** `python -c "import routing.solver"` and `python -c "import routing.planner"` separately. A cycle that fails one way and not the other is confirmed immediately, and you now know which module is the fragile entry. 2. **Read the traceback as an import chain, top-down.** Each frame is a module body that was executing when the next import began. The first module listed is the one left partially initialized; the last frame is the statement that asked for the missing name. The pair of them is the edge to cut. 3. **See the whole tree with `python -X importtime`.** It prints every module imported and the time each took, in order and nested, which shows how you reached the module and gives you the import cost at the same time. 4. **Note that retrying does not help.** When a module body raises, the import system removes that module from `sys.modules`, and the exception propagates outward removing the enclosing one too. Catching `ImportError` and importing again just re-runs both bodies and reproduces the identical failure. ### Fixing it so no order fails Re-ordering statements, or moving an import below the definitions it needs, makes today's order work and leaves the trap armed. Prefer, in order: * **Extract the shared name** — the type, constant or helper both modules need — into a third module that imports neither. The cycle is gone for every entry order, and the dependency direction becomes something a reader can see. * **Make one direction lazy on purpose**, importing the module rather than the name and reading attributes at call time, when one of the two really is a lower layer being called back into. * **Guard typing-only edges** with `if TYPE_CHECKING:`. ### Keeping it fixed The cheap regression test is an import test: in a fresh subprocess, import each public module of the package on its own and assert it succeeds. It costs milliseconds per module, it exercises every entry order rather than the one your app happens to use, and it fails on the commit that reintroduces the cycle instead of six months later in a new worker. Where the dependency direction between packages *should* point is a design question owned by architecture guidance rather than by the import system — but the import test is what makes the answer enforceable.

  • How do you read which module was left half-built out of the traceback?
    The traceback frames are the import chain in order: the first module body listed is the one that started the cycle and is therefore the partially initialized one, and the last frame is the `from ... import` that asked for a name it had not bound yet. The exception message names the same half-built module, so the two agree — and together they identify the exact edge to cut.
  • What cheap test stops the cycle coming back?
    Import each public module of the package on its own in a fresh subprocess and assert it succeeds. That exercises every entry order rather than the single one your application happens to use, runs in milliseconds per module, and fails on the commit that reintroduces the cycle instead of months later when a new worker or CLI entry point imports a submodule directly.
  • Why can a child process hit a cycle the parent never did?
    Start methods other than fork re-import modules in the child rather than inheriting the parent's already-populated module cache, and the child's entry point is usually a specific target module rather than the application's normal one. That is a different import order, so a latent cycle can fail only in children. On 3.14 the default start method on Unix other than macOS is forkserver, which re-imports.

saying these in an interview costs you the question

  • Assumes a cycle either always works or always fails
  • Says re-ordering the import lines is the fix
  • Thinks the module named in the error is the one that raised
  • Catches ImportError and retries the import
  • Only ever tests through the application entry point
  • Treats a deferred import as proof the cycle is gone

context