Implera is currently offline. The blog stays up.
Back to insights

Insights

How to Fix Circular Imports in Python

ImportError: cannot import name 'User' from partially initialized module 'app.models' (most likely due to a circular import). If you write Python for long enough, this error finds you. It usually arrives after a refactor that looked harmless, in a module nobody touched, and the traceback points everywhere except the actual problem.

This is the Python companion to our understanding circular dependencies post (the JavaScript version is here). It covers what the interpreter actually does when it hits a cycle, how to find every cycle in a codebase in one command, and the four patterns that fix them.

What Python actually does with a cycle

Python executes a module top to bottom the first time it is imported, and caches the module object in sys.modules before that execution finishes. That ordering is the whole story.

Say orders.py starts with from app.users import get_user, and users.py starts with from app.orders import Order. Import orders first and this happens:

  1. Python registers app.orders in sys.modules as an empty module object and starts executing it.
  2. Line one triggers the import of app.users, which starts executing.
  3. Line one of users.py asks for Order from app.orders. The module object exists in sys.modules, but execution stopped at line one, so Order is not defined on it yet.
  4. ImportError: cannot import name 'Order' from partially initialized module.

Two details worth knowing. First, import app.orders (module form) survives cycles that from app.orders import Order (name form) does not, because the name lookup is deferred until the attribute is used. Second, the error depends on entry point: the same cycle can crash under pytest but work fine under python main.py, because the import graph is walked in a different order. A cycle that "only breaks in tests" is still a cycle. The full mechanics are in the Python import system reference.

Where cycles come from in real Python codebases

Four shapes account for nearly all of them.

Mutual model imports. models/user.py needs Order for a relationship, models/order.py needs User for the same relationship. Every ORM codebase grows this one eventually.

The fat __init__.py. A package __init__.py that imports every submodule for convenience is Python's version of the barrel file. Any submodule that then imports from its own package (from app.models import User inside app/models/order.py) routes through __init__.py and creates a cycle.

Type hints. def process(order: Order) -> Invoice: forces a runtime import of both classes, even though the annotation is never executed as logic. Type-hint cycles are the most common kind in modern annotated codebases, and the easiest to fix.

Utility creep. A utils.py or helpers.py that started generic and gradually imported domain modules. Once utils imports from models and models imports from utils, everything that touches either is inside the ring.

Find every cycle in one command

Do not fix cycles by chasing tracebacks. Enumerate them first.

pylint. The cyclic-import check (R0401) ships with pylint and needs no configuration:

pylint app/ --disable=all --enable=cyclic-import

import-linter. The better tool for keeping cycles out permanently. You declare contracts in config and it fails CI when they break. The independence and layers contract types both forbid cycles between the modules they cover:

[importlinter]
root_package = app

[importlinter:contract:layers]
name = Layered architecture
type = layers
layers =
    app.api
    app.services
    app.models

Run lint-imports in CI and a new cycle fails the build the moment it lands. The import-linter documentation covers the contract types.

A codebase that has never checked usually has between two and twenty cycles. List them, then apply the fixes below in order of cheapness.

Fix one: make type-hint cycles free

If the only reason for the import is an annotation, the fix costs two lines. Guard the import with TYPE_CHECKING, which is False at runtime, and use the deferred annotation behaviour so the name is never evaluated:

from __future__ import annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from app.orders import Order

def process(order: Order) -> None:
    ...

The import now exists only for mypy, pyright and your editor. At runtime it never happens, so the cycle is gone. In an annotated codebase this single pattern typically clears half the cycle list in one pass. Ruff's TCH rules will even move eligible imports into the TYPE_CHECKING block automatically.

Fix two: extract the shared module

When two modules both need the same class, constant or schema at runtime, neither should own it. Pull it into a third module both import:

# Before: app/users.py and app/orders.py import each other
# After:
# app/schemas.py
class OrderSummary(TypedDict):
    order_id: str
    total: int

# app/users.py
from app.schemas import OrderSummary

# app/orders.py
from app.schemas import OrderSummary

This is the same "pull the shared contract up" move as in any language, and it usually improves the design: the cycle was the codebase telling you a boundary was drawn in the wrong place.

Fix three: defer the import into the function

Moving an import from module level to inside the function that uses it breaks the cycle, because by the time the function is called, both modules have finished initialising:

def notify_user(order_id: str) -> None:
    from app.users import get_user  # deferred, cycle resolved by call time
    user = get_user(order_id)

Be honest about what this is: a workaround, not a fix. The structure still has a cycle; you have just hidden it from the import walker. It is the right call when the alternative is restructuring a package mid-incident, and the wrong call as a permanent pattern. Leave a comment, file the debt, and prefer fixes one and two when you have time.

Fix four: empty the __init__.py

The convenience-import __init__.py gives you slightly shorter import paths and, in exchange, cycles, slower startup and eager loading of everything in the package. The trade is bad.

Two rules keep packages cycle-free:

  • Inside a package, import siblings directly. app/models/order.py imports from app.models.user import User, never from app.models import User.
  • Keep __init__.py empty or close to it. If external callers need a curated public surface, re-export a handful of names and stop there. Nothing inside the package may import from its own __init__.py.

In package-heavy codebases this is the highest-leverage structural fix, for exactly the same reason killing barrel files is in TypeScript.

Keep them out

Fixing the current list is an afternoon. Staying at zero is a CI rule.

  1. Run pylint's cyclic-import or import-linter across the codebase and record the count.
  2. Clear the type-hint cycles with TYPE_CHECKING (cheapest, biggest batch).
  3. Extract shared contracts for the real runtime cycles.
  4. Strip convenience imports out of __init__.py files.
  5. Add lint-imports to CI so the count can never rise again.

Import cycles are one of the structural signals Implera's architecture domain scores on every analysis, alongside change coupling and module structure, so a new ring shows up as a score drop rather than a Friday-afternoon ImportError. The wider structural playbook is in how to improve your codebase architecture.

FAQ

Common questions

© 2026 Implera