Skip to content
Goatfied

refactoring

Detecting and fixing circular imports automatically

This post explains how to automatically detect circular import cycles in Python codebases and apply safe refactoring strategies to resolve them without breaking existing functionality.

2026-09-028 min readBy Goatfied
Detecting and fixing circular imports automatically

A Python module imports auth.py, which imports database.py, which imports models.py, which circles back to auth.py. Your tests pass locally, but CI fails with ImportError: cannot import name 'get_current_user'. You trace the dependency chain manually, realize the cycle involves five files across three packages, and spend two hours unwinding it. The immediate fix works, but three weeks later a teammate reintroduces the same cycle in a different part of the graph.

Circular imports surface at the worst times—late in CI pipelines, during hot deploys, or when unrelated changes suddenly break initialization order. Manual detection is tedious and error-prone at scale, especially in codebases where modules have implicit runtime dependencies that only resolve after all imports complete. This post walks through automatic detection strategies, shows what makes cycles tricky to fix without breaking working code, and demonstrates how an agent-driven workflow can propose safe rewrites while validating that nothing regresses.

Why circular imports hide until they don't

Python executes imports top-to-bottom during module initialization. If a.py imports b, and b imports a, Python starts loading a, encounters the import b line, switches to loading b, then hits import a again. At that point a is only partially initialized—names defined after the second import don't exist yet. Whether you see an error depends on when each name is referenced:


# auth.py

from database import get_connection  # runs immediately



def authenticate(token):

    conn = get_connection()

    # ...



# database.py

from auth import authenticate  # also runs immediately



def get_connection():

    # ...

This fails immediately because database.py tries to import authenticate before auth.py finishes defining it. But if you change the imports to run inside functions:


# auth.py

def authenticate(token):

    from database import get_connection  # deferred

    conn = get_connection()



# database.py

def get_connection():

    from auth import authenticate  # deferred

    # ...

…it works at runtime, as long as neither function calls the other during module initialization. The cycle still exists in the dependency graph, but Python's lazy evaluation hides it until someone refactors the code and moves an import to module scope.

Static analyzers like mypy or pylint can flag these cycles early, but they won't tell you how to fix them without violating separation of concerns or breaking existing call sites. Automated fixes require understanding the semantic roles of the entangled modules.

Building a dependency graph from real imports

Detecting cycles means constructing a directed graph of module-to-module dependencies. The simplest approach parses import and from ... import statements at the top of each file:


import ast

from pathlib import Path

from collections import defaultdict



def extract_imports(source_path):

    tree = ast.parse(source_path.read_text())

    imports = set()

    for node in ast.walk(tree):

        if isinstance(node, ast.Import):

            for alias in node.names:

                imports.add(alias.name.split('.')[0])

        elif isinstance(node, ast.ImportFrom):

            if node.module:

                imports.add(node.module.split('.')[0])

    return imports

For a small codebase, you can store edges in a dict and use Tarjan's algorithm or depth-first search to find strongly connected components. Any SCC with more than one node is a cycle.

The tricky part is dynamic imports—importlib.import_module(config.module_name) or conditional imports inside try-except blocks. These won't appear in the AST unless you evaluate runtime values. For those cases, instrumenting the import system with sys.meta_path hooks or running the code under coverage to record actual import chains gives you the real dependency graph. The tradeoff is that you need a working environment where all dependencies resolve, which may not be available in a clean CI checkout.

Fixing cycles by extracting shared interfaces

Once you've identified a cycle, the fix usually falls into one of three categories:

1. Extract a shared base module. If auth.py and database.py both depend on each other for a small set of types or constants, move those to a new common.py that both import. This works when the overlap is conceptual—shared config, enums, or protocol definitions.

2. Invert the dependency. Make one module depend on an interface the other provides, rather than importing the concrete implementation. For example, if database.py calls auth.authenticate() to validate connections, pass a callable into get_connection(authenticator=...) so database.py never imports auth directly.

3. Defer the import to function scope. If the circular reference is only needed inside a single function, move the import there. This is a quick tactical fix but doesn't remove the cycle from the dependency graph—it just delays evaluation.

The challenge is choosing the right strategy without breaking existing behavior. If auth.py has 15 functions and only two reference database, extracting a shared interface requires deciding which abstractions belong in the new module and confirming that every caller still works after the split.

Agent-driven rewrites with validation gates

An automated fix needs to parse the cycle, propose a refactor, apply the changes across multiple files, and validate that tests still pass. Goatfied's agent loop handles this by generating a plan, constraining edits to small reversible diffs, and retrying if compilation or tests fail:

1. Plan: The agent identifies the cycle using static analysis (or instrumented runtime tracing), determines which modules are most tightly coupled, and decides whether to extract a shared module or invert a dependency.

2. Constrain: Instead of rewriting entire files, the agent produces targeted edits—move three type definitions to auth/types.py, update import statements in auth/session.py and database/pool.py, add a new from auth.types import Token at the top of api/routes.py.

3. Edit: Each change is a small, line-precise diff. The agent stages them in logical order so intermediate states still parse, even if they don't run yet.

4. Validate: After each group of edits, Goatfied runs your language server (for type-checking), linters, and the test suite. If any gate fails, the agent sees the error output and revises the plan—maybe the new types.py needs an __init__.py, or a function signature changed in an unexpected way.

5. Retry: The agent iterates until all validations pass or hits a budget limit. Because each edit is reversible and tracked, you can inspect intermediate states or roll back if the final result isn't what you want.

This loop matters for circular imports because the "correct" fix often isn't obvious until you try it. Extracting a shared module might reveal that auth and database also both depend on logging in a way that introduces a new cycle. The agent can detect this during validation, adjust the plan (move the logger setup to a separate bootstrap.py), and try again without manual trial-and-error.

Handling package-level cycles and relative imports

File-level cycles are one thing; package-level cycles are worse. If company/billing/__init__.py imports from company/users, and company/users/__init__.py imports from company/billing, you have a cycle that mypy will catch, but fixing it often means rethinking your package boundaries.

The same agent-driven approach works here, but the plan phase needs to reason about public APIs. If billing only needs users.models.Account, you might:

  • Create company/shared/account_types.py with just the Account dataclass.
  • Update users/__init__.py to import and re-export Account.
  • Change billing to import from shared instead of users.

Relative imports complicate this. A from ..users import Account in billing/invoices.py becomes invalid if you move Account to a sibling package. The agent needs to rewrite those to absolute imports or adjust the file structure so relative paths still resolve.

Goatfied's compile-first validation catches these issues immediately—if the new import path breaks, the Python interpreter or mypy will report it before the changes leave your workspace. The agent sees the error and retries with a corrected import.

Keeping cycles from reappearing

Fixing a cycle once is useful. Preventing it from coming back is better. Post-fix, you can:

  • Add a linting rule that blocks imports in the directions you just removed. Tools like import-linter let you define layers (shared may not import from billing) and fail CI if new code violates them.
  • Run cycle detection in pre-commit hooks or as a required CI check. A lightweight script that builds the import graph and checks for SCCs takes seconds and surfaces issues before they merge.
  • Document the intended dependency flow in a CONTRIBUTING.md or architectural decision record. This helps teammates understand why auth shouldn't import database directly, reducing accidental reintroductions.

For larger codebases, consider a dependency graph visualization that updates on every PR. Seeing a new edge that closes a cycle in a diff review is much easier than debugging it in production.

When to fix cycles manually

Automated fixes work best when the cycle involves a clear, localized abstraction—a few types, a shared constant, or a small utility function. If the cycle spans ten modules and each one imports half of the others' APIs, the underlying issue is likely architectural. In that case, the agent can still detect and report the problem, but the solution might be a larger refactor: splitting a monolithic package, introducing a plugin system, or redesigning the initialization order.

Even in those scenarios, an agent-driven workflow helps by proposing incremental steps. Instead of "rewrite the entire billing module," you get "extract these five functions to a new billing/core.py, update six import sites, verify tests pass." Each step is small enough to review and validate independently.

Related posts

Detecting and fixing circular imports automatically | Goatfied Blog