Skip to content
← All articles

Circular imports: diagnosis and the four real fixes

A cycle is a design smell rendered as a crash. Teams fix it with a function-local import, which converts a compile-time architectural problem into a scattered runtime one.

ImportError: cannot import name 'Order' from partially initialized module
'app.models' (most likely due to a circular import)

The message is accurate and the usual response is not. Somebody moves the import inside the function, the error goes away, and the actual finding — these two modules are not two modules — is lost.

Why it fails, precisely

The loader inserts a module object into sys.modules before executing its body. That is deliberate: it is what stops an import cycle from recursing forever. It also means a module can be observed half-built.

Now trace import app.models where models imports services and services imports models:

  1. app.models is created, empty, and put in sys.modules.
  2. Its body runs and hits import app.services.
  3. app.services is created and its body runs.
  4. It hits from app.models import Order. app.models is in sys.modules — so no re-import happens. Python looks up the attribute Order on the half-built module. Order has not been defined yet.
  5. ImportError.

The FAQ states the consequence exactly: a circular import is fine when both modules use import module and refer to module.Name later, at call time. It fails when the second module wants a name out of the first at module top level, because the first is still mid-execution.

So import app.models followed by app.models.Order inside a function works. from app.models import Order at the top does not. Same cycle, and the difference is when the name is looked up.

💡A cycle exists but nothing crashes, and it has been that way for a year. Then somebody reorders two imports and the build breaks. What changed? click to reveal

Nothing about the cycle. What changed is which module the interpreter entered first.

In a two-module cycle, one of them is always the entry point and gets fully executed; the other observes it half-built. Whether that is a problem depends on which names the half-built module needs and whether they were defined above or below the import statement. A cycle can therefore be latent for years and become fatal because of an alphabetised import block, a new __init__.py re-export, or a test importing the pair in the other order.

This is why “it works” is not evidence that a cycle is safe. It is evidence that today’s entry order happens to be the working one. The graph is the thing to fix, not the ordering.

The four fixes, in order of preference

1. Restructure. The cycle is information: either these two modules are really one, or a type they both need belongs in a third module that neither imports back. Most cycles in real code are a modelsservices pair where a couple of shared dataclasses want to live in domain. This is the only fix that makes the graph better.

2. Invert the dependency with a Protocol. If services needs something from models and models needs something from services, one of those directions is wrong. Have the inner module declare a Protocol describing what it needs, and let the outer module satisfy it structurally. No import points back, and the checker verifies the contract.

3. if TYPE_CHECKING: for annotation-only imports. If the import exists purely to write an annotation, put it in a TYPE_CHECKING block and quote the annotation (or rely on from __future__ import annotations). The name is never looked up at runtime, so the cycle never forms. This is legitimate and common — it is what ruff’s TC rules are for.

4. A function-local import, as a last resort. It works: by the time the function runs, both modules are fully initialised. Be honest about what it costs:

  • a dict lookup in sys.modules on every call — small, but not free in a hot loop;
  • it is invisible to import-graph tooling, so your linter’s layering contract stops seeing the edge;
  • it moves ImportError from process start to whenever that branch first executes, which may be in production, on the error path, at 3 a.m.
💡Someone suggests PEP 810's lazy import as a general fix for circular imports. Is it? click to reveal

No. Two limitations make it the wrong tool for this job.

It does not fix cycles in the general case — deferring the binding changes when the partially-initialised module is observed, not whether it can be. A lazy import that is first touched while the other module is still executing fails in exactly the same way.

And it is module-scope only, so it cannot replace the function-local import in the cases where the function-local import is genuinely needed.

Related, since it comes up in the same conversation: PEP 781, which would make TYPE_CHECKING a builtin so you could drop the from typing import TYPE_CHECKING line, is still Draft and did not land in 3.15. Keep importing it.

The trap that passes review

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from app.models import Order


def load(order_id: int) -> "Order":
    return Order.get(order_id)      # NameError at runtime

Order is used as a value, not just an annotation. mypy is perfectly happy — the name exists as far as the checker is concerned. At runtime the if was false, Order was never bound, and the first call raises NameError.

Ruff catches it: TC004, “move import out of type-checking block; used for more than type hinting”. It is worth enabling the whole TC family for this one rule alone.

Making the graph visible

You cannot fix what you cannot see. python -X importtime -c "import app" shows the import tree; import-linter’s forbidden and layers contracts fail the build on an edge you have declared illegal; ruff analyze graph emits the dependency graph as JSON.

The problem below is the primitive underneath all of those: find every elementary cycle in an import graph, deterministically. Once you can produce that list, “no cycles” becomes a test rather than a habit.