Python Circular Imports: How to Find and Fix Circular Dependencies (Updated 10JUN26)

Learn why Python circular imports happen, how partially initialized modules cause runtime failures, and how dependency graphs help expose and fix architectural cycles.

•19 min read
pythoncircular dependenciesimportsarchitecture

If you've worked on a Python codebase for more than a few months, you've probably seen this error:

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

A circular import happens when two or more Python modules depend on each other during import-time execution. Python begins loading one module, stores a partially initialized version in sys.modules, then another module imports it before it has finished executing. The result is an incomplete module object: some names exist, but others have not been defined yet.

That is why circular imports often feel random. They are not random. They are load-order problems caused by dependency cycles.

The common quick fix is to move the import inside a function. The error goes away. The app starts. Everyone moves on.

But the underlying structure usually remains. Six months later, the codebase has a dozen lazy imports, tests that fail depending on discovery order, and architectural coupling that nobody fully understands. The import workaround becomes a permanent fixture, and it brings friends.

The import error was the symptom. The dependency graph is the real problem.

Python's import system makes circular dependencies surprisingly easy to create and deceptively hard to notice. Unlike some compiled languages where dependency cycles are caught during build or linking, Python often lets them surface at runtime — or worse, only when a specific execution path is triggered in production.

Let's unpack what is actually happening, how to reproduce it, and how to fix it systematically.

A Minimal Circular Import Example

Start with three files:

# user.py
from project import Project

class User:
    def projects(self):
        return [Project()]
# project.py
from user import User

class Project:
    def owner(self):
        return User()
# main.py
from user import User

user = User()
print(user.projects())

Run:

python main.py

Python starts importing user.py. It reaches:

from project import Project

So Python begins importing project.py. Then project.py reaches:

from user import User

But user.py has not finished executing yet. The User class has not been defined. Python has only placed a partially initialized user module in sys.modules.

The import chain looks like this:

main.py
  → user.py
      → project.py
          → user.py again, but partial

That is the root of the “partially initialized module” error.

How Python's Import System Actually Works

Understanding circular imports requires understanding what Python does during import. When you execute import user, Python:

  1. Checks sys.modules to see whether user is already loaded.
  2. If it is not present, creates a new empty module object.
  3. Inserts that module object into sys.modules.
  4. Executes user.py line by line.
  5. If execution completes, the module is fully initialized.

The trap is step 3.

Python places the module in sys.modules before the file has finished executing. This is necessary to avoid infinite recursion during imports, but it also means another module can receive an incomplete version of the module.

If another module imports user while user.py is still executing, Python does not start over. It returns the partially initialized module object that already exists in sys.modules.

Attributes defined later in the file may not exist yet.

That is what “partially initialized module” means. It is not magic, and it is not random. It is a race between module execution paths.

The Import-Time Execution Trap

Python executes top-level code at import time:

# user.py
print("Executing user.py")  # Runs when imported

from project import Project

class User:
    def get_projects(self):
        return Project.objects.filter(user=self)

When you import user, Python starts executing user.py line by line. It hits from project import Project, which triggers execution of project.py. If project.py imports user, Python returns the incomplete user module already in sys.modules.

At that moment, the file stopped executing before class User was defined.

user.py → project.py → user.py, partial module, User not yet defined

That is the circular import failure in concrete terms.

Why Type Hints Make This Easier to Trigger

Python type annotations often make circular imports more tempting. You may not need a class at runtime, but you want to import it for type checking:

# user.py
from project import Project

class User:
    def get_projects(self) -> list[Project]:
        ...

Then the other side does the same thing:

# project.py
from user import User

class Project:
    def get_owner(self) -> User:
        ...

Now the modules depend on each other just to express type relationships.

Modern Python gives you tools to reduce this pressure. from __future__ import annotations postpones annotation evaluation, and typing.TYPE_CHECKING lets you import types only for static analysis. But in real-world codebases that use runtime introspection — ORMs, serializers, Pydantic models, plugin systems, dependency injection frameworks — type-related imports can still become runtime import edges if you are not careful.

Why Circular Imports Are Worse Than They Look

Most teams treat circular imports as nuisances. Something breaks, someone moves an import into a function, and the code starts working again.

But circular imports are usually structural warnings. They mean your module graph contains a cycle.

Well-structured software tends to form a directed acyclic graph:

API
 ↓
Services
 ↓
Domain
 ↓
Infrastructure

Higher-level modules depend on lower-level modules. Dependencies flow in one direction. If dependencies loop back upward, the graph no longer has clean layers.

A circular dependency means your graph contains a strongly connected component: a set of modules where each module can reach the others.

A → B → C → A

That is not just inconvenient. It is a structural knot. No module inside the cycle can evolve independently without potentially affecting the rest of the cycle.

In practice, the largest strongly connected component in a Python codebase often captures the system's architectural pain. If your core domain lives inside a six-module import cycle, refactoring becomes risky because changes are no longer isolated.

Non-Deterministic Failures

Circular imports can make test behavior depend on import order.

Consider:

# This might work
from order import Order
from payment import Payment

But this might fail:

# This might fail
from payment import Payment
from order import Order

Same modules. Different order. Different result.

The first import path may fully initialize the needed class before the second module asks for it. The second path may request a name from a partially initialized module.

Test runners, environment differences, and pytest discovery order can all alter import order. That means circular imports make behavior depend on load order rather than application logic.

That is the worst category of bug: it passes locally, fails in CI, then disappears when you add a print statement.

Hidden Runtime Landmines

Moving imports inside functions is the most common workaround:

def process_order(order_id):
    from payment import charge_card
    charge_card(order_id)

This can be a valid short-term fix. It defers the import until after module initialization is complete.

But it also hides the dependency from the top-level import graph. The startup error disappears, but the architectural relationship may still exist.

Now the dependency is triggered only when the function runs. If that path is rare, the failure may move from application startup to production runtime. If the function is called only under a specific feature flag, background job, or error path, the cycle can sit undetected for weeks.

Lazy imports are not always bad. They are sometimes useful for performance or optional dependencies. But lazy imports used to paper over circular dependencies should be treated as warning signs, not permanent architecture.

How Cycles Accumulate

Circular dependencies rarely appear intentionally. They usually accumulate through normal feature work.

Phase 1 — Initial simplicity

order.py → payment.py

Order logic calls payment logic. Clean and directional.

Phase 2 — Feature addition

Payment needs to notify Order of status changes:

payment.py → order.py

Now you have:

order.py ↔ payment.py

Phase 3 — More behavior is added

Transactions, notifications, audit logs, and validation enter the graph:

order.py → payment.py → transaction.py → order.py

Phase 4 — Workarounds appear

Lazy imports, string references, helper modules, and __init__.py re-exports make the code “work,” but the graph no longer communicates clean structure.

That is architectural entropy. The dependency graph stops telling a story and becomes noise.

Visualizing the Problem

A representative cycle might look like this:

Circular dependency chain:

order.py → payment.py → transaction.py → order.py

order.py       line 12: from payment import PaymentProcessor
payment.py    line 8:  from transaction import Transaction
transaction.py line 15: from order import Order

Visualized as a graph:

        ┌──────────┐
    ┌───┤  Order   ├───┐
    │   └──────────┘   │
    │                  │
    ▼                  ▼
┌─────────┐      ┌──────────────┐
│ Payment │─────▶│ Transaction  │
└─────────┘      └──────────────┘
    ▲                  │
    └──────────────────┘
       back to Payment/Order

This is not only an import issue. These concepts are tightly coupled. A change to one can affect the others because the graph has no clean boundary.

The import cycle is the graph telling you something about your design.

Common Circular Import Patterns and Fixes

There is a hierarchy of fixes. Some fix runtime behavior. Others fix structure.

PatternCommon fixWhat it improves
Type-only importsTYPE_CHECKING, postponed annotations, string annotationsRuntime behavior
Django model referencesString foreign keys, app-label model referencesRuntime behavior
Shared constants or typesMove shared items to a neutral moduleStructure
Services calling each otherEvents, dependency inversion, orchestration layerStructure
Bloated utility modulesSplit utilities by purpose, avoid domain importsStructure
Layer violationsEnforce directional architectureRoot cause

The important question is not only “How do I make the import error go away?” It is:

What dependency edge created this cycle, and should that edge exist?

Pattern 1: Mutual Type References

This is common in modern Python:

# user.py
from project import Project

class User:
    def get_projects(self) -> list[Project]:
        ...
# project.py
from user import User

class Project:
    def get_owner(self) -> User:
        ...

If the imports are needed only for type checking, use TYPE_CHECKING.

# user.py
from __future__ import annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from project import Project

class User:
    def get_projects(self) -> list[Project]:
        ...
# project.py
from __future__ import annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from user import User

class Project:
    def get_owner(self) -> User:
        ...

TYPE_CHECKING is False at runtime, so the guarded imports never execute during application startup. Static type checkers still see them.

If you need runtime access, import inside the function deliberately:

class User:
    def get_projects(self):
        from project import Project
        return Project.objects.filter(user=self)

That is a runtime workaround, not a structural refactor. It may be acceptable, but it should be intentional.

Pattern 2: Use Protocols Instead of Concrete Imports

Sometimes the real issue is that one module depends on a concrete class when it only needs a small interface.

Instead of this:

# payment_service.py
from order_service import OrderService

class PaymentService:
    def charge(self, order):
        OrderService.update_status(order, "paid")

Define an interface in a neutral module:

# shared/interfaces.py
from typing import Protocol

class OrderUpdater(Protocol):
    def update_status(self, order_id: int, status: str) -> None:
        ...

Then depend on the interface:

# payment_service.py
from shared.interfaces import OrderUpdater

class PaymentService:
    def __init__(self, order_updater: OrderUpdater):
        self.order_updater = order_updater

    def charge(self, order_id: int):
        # payment logic...
        self.order_updater.update_status(order_id, "paid")

The dependency now points toward a stable abstraction instead of back into another service.

Pattern 3: Django Model Foreign Keys

Django projects often create circular imports through model references:

# models/order.py
from models.user import User

class Order(models.Model):
    customer = models.ForeignKey(User, on_delete=models.CASCADE)
# models/user.py
from models.order import Order

class User(models.Model):
    def get_orders(self):
        return Order.objects.filter(customer=self)

Use a string reference for the foreign key:

# models/order.py
class Order(models.Model):
    customer = models.ForeignKey(
        "user.User",
        on_delete=models.CASCADE,
    )

Django resolves string model references after apps are loaded. That avoids the import edge at module initialization time.

For methods that need the related model, a local import may be acceptable:

# models/user.py
class User(models.Model):
    def get_orders(self):
        from models.order import Order
        return Order.objects.filter(customer=self)

For larger systems, consider moving query behavior into a service or repository layer so models do not need to import each other directly.

Pattern 4: Services Calling Each Other

Business logic creates the most architecturally significant cycles.

# order_service.py
from payment_service import PaymentService

class OrderService:
    def complete_order(self, order):
        PaymentService.charge(order)
# payment_service.py
from order_service import OrderService

class PaymentService:
    @staticmethod
    def charge(order):
        OrderService.update_status(order, "paid")

This is not just an import problem. Two services that call each other directly are not really independent services.

One fix is to introduce an orchestration layer:

# checkout_service.py
from order_service import OrderService
from payment_service import PaymentService

class CheckoutService:
    def complete_checkout(self, order_id):
        order = OrderService.get_order(order_id)
        PaymentService.charge(order)
        OrderService.update_status(order, "paid")

Now OrderService and PaymentService do not import each other. The workflow lives one layer above them.

Another fix is to use events:

# order_service.py
from events import publish

class OrderService:
    def complete_order(self, order):
        publish(OrderCompleted(order))
# payment_service.py
from events import subscribe, publish

@subscribe(OrderCompleted)
def handle_order_completed(event):
    PaymentService.charge(event.order)
    publish(PaymentProcessed(event.order))

Events are not always necessary, but they are useful when services need to react to each other without direct imports.

Pattern 5: Utility Module Sprawl

Utility modules often become dependency traps because they accumulate unrelated behavior.

# utils.py
from models import User, Order

def format_order_summary(order):
    return f"Order {order.id} for {order.user.name}"
# models.py
from utils import format_order_summary

class Order:
    def summary(self):
        return format_order_summary(self)

Now utils.py imports models, and models import utils.py.

Fix it by keeping utilities generic:

# utils/formatting.py
def format_order_summary(order_id: int, user_name: str) -> str:
    return f"Order {order_id} for {user_name}"
# models.py
from utils.formatting import format_order_summary

class Order:
    def summary(self):
        return format_order_summary(self.id, self.user.name)

If a utility function needs a complex domain object, it may not belong in utils. It may belong closer to the domain that owns that object.

Pattern 6: Web Framework Cycles

FastAPI and Flask have their own circular import patterns.

In FastAPI, dependencies and routes can easily import each other:

# api/dependencies.py
from api.routes.orders import verify_order_permission

If routes also import dependencies, you have a cycle.

Push shared logic into a lower layer:

# domain/auth.py
def get_user_from_token(token):
    ...
# api/dependencies.py
from domain.auth import get_user_from_token

Routes depend on dependencies. Dependencies depend on domain logic. Domain logic does not import routes.

In Flask, blueprints can fall into the same pattern:

# blueprints/users.py
from blueprints.orders import orders_bp

Blueprints should usually not import each other directly. Shared behavior belongs in services:

# services/order_service.py
def get_orders_for_user(user_id):
    ...
# blueprints/users.py
from services.order_service import get_orders_for_user

Again, the fix is directional layering.

The Structural Cure: Enforced Layering

Runtime fixes are valid, but the root-cause solution is enforcing directional layers:

API
 ↓
Services
 ↓
Domain
 ↓
Infrastructure

Each layer can only depend downward. If you follow this strictly, circular imports become impossible by construction. There is no path for a cycle to form.

The exact layer names do not matter. The rule matters.

For example:

  • API routes should not be imported by domain code.
  • Services should not import API dependencies.
  • Models should not import route handlers.
  • Utilities should not import high-level domain objects.
  • __init__.py should not re-export half the application.

A few __init__.py traps are worth calling out:

# models/__init__.py
from .user import User
from .order import Order
from .payment import Payment

This looks convenient, but it means every consumer of models may implicitly touch user, order, and payment. Re-exports create hidden edges. They can make the import graph harder for both humans and tools to understand.

Prefer explicit imports:

from models.user import User
from models.order import Order

How to Detect Circular Imports

For small cases, the traceback is enough. Read the import chain and identify which modules import each other during initialization.

For larger projects, tracebacks are not enough. You need the dependency graph.

A practical detection workflow looks like this:

  1. Generate a module dependency graph.
  2. Identify strongly connected components.
  3. Sort cycles by size and location.
  4. Prioritize cycles in core business logic or high-churn code.
  5. Fix one dependency edge at a time.

A two-file cycle in a stable script may not matter much. A seven-module cycle through your domain, services, and API layer is a much bigger signal.

Static graph tools can help here. The hosted PViz app can visualize dependency structure and surface strongly connected components for architectural review.

The goal is not simply to list circular imports. The goal is to see which cycles matter.

Classify Before You Fix

For each cycle, ask what kind of dependency created it:

  • Is it type-only?
  • Is it an ORM relationship?
  • Is it service-layer coupling?
  • Is it a misplaced utility?
  • Is it an __init__.py re-export?
  • Is it a layer violation?

Each class has a different fix. Applying the wrong fix wastes time and may only hide the cycle.

For example, a type-only cycle may be solved with TYPE_CHECKING. A service-layer cycle probably needs orchestration, events, dependency inversion, or a boundary change. A utility cycle may need the utility split apart.

Do not treat every cycle the same.

Refactor Incrementally

Do not try to untangle the entire graph in one pass.

Break cycles one edge at a time:

  • Move shared constants to a neutral module.
  • Replace concrete imports with protocols.
  • Move workflow coordination into an orchestrator.
  • Replace direct service calls with events.
  • Remove broad __init__.py re-exports.
  • Convert type-only imports to TYPE_CHECKING.
  • Push framework-specific code upward instead of letting it leak downward.

Each individual change can be small. The cumulative effect is a graph that flows in one direction.

Enforce the Direction Going Forward

Once the graph is healthier, prevent regressions.

For JavaScript and TypeScript, tools like dependency-cruiser can fail a pull request when cycles appear. For Python, you can combine local graph generation with a small script that fails CI when new strongly connected components are introduced.

PViz is useful for architectural review and visual inspection. CI enforcement can be layered on top by checking graph output for new cycles.

The goal is not perfection on day one. It is intentionality. If a cycle is acceptable, document it. If it is accidental, remove it before it becomes part of the architecture.

What Elimination Actually Looks Like

Imagine a Django codebase that has accumulated cycles over two years of feature development. New engineers spend weeks feeling nervous about changes. The team has a running joke about “import roulette” because the test suite fails differently depending on which file pytest discovers first.

A systematic cleanup might unfold in three phases.

First, the type-hint sweep. Many cycles in modern Python codebases are type annotation imports that never needed to run at runtime. These are often mechanical, low-risk fixes.

Second, the service-layer work. The genuinely architectural cycles — the ones where two services call directly into each other — get refactored toward orchestration, dependency inversion, or events. This is slower and requires design thinking.

Third, the prevention layer. The team adds cycle detection to review or CI, and agrees on a policy for intentional exceptions.

The goal is not architectural purity. It is architectural visibility.

A dependency graph should tell a coherent story. When a new engineer can look at the module graph and understand how data flows through the system, you have done the work. Test order stops mattering. Startup errors disappear. Refactoring becomes something people actually do instead of avoid.

You go from a knot to a flow.

Python-Specific Best Practices

Use absolute imports. Relative imports can obscure the real dependency relationship.

# Harder to reason about
from ..models import User
from .services import OrderService

# Clearer
from app.models.user import User
from app.services.order_service import OrderService

Prefer explicit imports over re-exports. __init__.py re-exports hide edges. Let consumers import from the module that defines the thing.

from app.models.user import User

is clearer than:

from app.models import User

when app.models re-exports many modules.

Separate type dependencies from runtime dependencies. Use TYPE_CHECKING, postponed annotations, protocols, and string references when a dependency exists only for type checking.

Use lazy imports deliberately. Lazy imports for performance or optional dependencies are valid. Lazy imports used to hide circular dependencies should be treated as temporary.

Watch framework boundaries. Routes, serializers, models, and services should not all import each other freely. Framework code should sit at the edge of the system, not in the center of the dependency graph.

Conclusion: Circular Imports Are Architectural Warnings

Circular imports in Python are easy to work around but dangerous to ignore. They signal missing boundaries, layer violations, over-coupled services, and domain leakage.

Python makes cycles easy. Good architecture makes them harder to create.

The fix is not always a grand redesign. Often it is one edge at a time: a type-only import moved behind TYPE_CHECKING, a shared interface extracted, a service boundary clarified, a broad re-export removed.

Your import graph should tell a story about your architecture. If that story is circular, your architecture needs attention.

Visualize the dependency graph. Find the strongly connected components. Break the highest-value cycles first. Then enforce direction so the same knots do not come back.


Detect circular imports in your Python project: Generate a dependency graph for your codebase and look for strongly connected components. PViz can help visualize those cycles in the hosted app for supported repositories.

Next in this series: Onboarding Metrics: Measuring Codebase Complexity — how to quantify onboarding difficulty using dependency analysis and graph metrics.

Try PViz on your own codebase

Get dependency graphs, coupling signals, and a compressed bundle ready for your LLM — for any GitHub repository, in minutes.