DEV Community

yureki_lab
yureki_lab

Posted on

How I Broke 37 Circular Imports in a Python Codebase With Claude Code

TL;DR

I had a ~180k-line Python service with 37 circular import chains, held together by function-level imports and prayer. I used Claude Code to map the import graph, rank the cycles, and break them one PR at a time — then added a CI contract so they can't come back. It took 8 working days. The biggest lesson: give the agent the graph, not the error message.

The Problem

If you've worked on a Python codebase that's older than three years, you've probably seen this:

ImportError: cannot import name 'InvoiceService' from partially initialized
module 'billing.services' (most likely due to a circular import)
Enter fullscreen mode Exit fullscreen mode

Our service — a billing and subscriptions backend, roughly 180,000 lines of Python 3.12, Django-ish layering but hand-rolled — had been "fixing" these for years the same way: move the import inside the function.

def create_invoice(customer_id: int):
    # avoid circular import
    from billing.services import InvoiceService
    ...
Enter fullscreen mode Exit fullscreen mode

I counted. There were 214 # avoid circular import comments in the repo. Every one of them was a tiny lie that said "these modules don't depend on each other at load time" when they absolutely did.

Why it actually mattered (not just aesthetics):

  • ⚠️ Import order bugs in tests. Running a single test file would pass; running the whole suite in a different order would explode. We had 6 tests marked "skip on CI" for exactly this reason.
  • ⚠️ Cold start time. Lazy imports were scattered across hot paths, so the first request to some endpoints paid a ~400 ms import tax.
  • ⚠️ Nobody could reason about layers. "Can models import from services?" The honest answer was "sometimes, depending on which file you're in."

I'd tried to fix this by hand once before. I got through four cycles in two days, broke a webhook handler in staging, and quietly gave up. This time I wanted to see if an AI coding agent could do the tedious part while I made the design calls.

How I Solved It

Step 1: Build the graph first (don't let the agent guess)

My first attempt was the naive one: paste the ImportError into Claude Code and say "fix this circular import." It fixed it — by adding another function-level import. Technically correct, completely useless.

The agent wasn't wrong; it just didn't have the full picture. A single traceback shows you one edge of a cycle. So I generated the whole import graph and handed that over instead.

I used pydeps to dump module dependencies as JSON, then a tiny script to find strongly connected components:

import json
import networkx as nx

deps = json.load(open("deps.json"))
g = nx.DiGraph()
for mod, info in deps.items():
    for imported in info.get("imports", []):
        if imported in deps:  # only first-party modules
            g.add_edge(mod, imported)

cycles = [c for c in nx.strongly_connected_components(g) if len(c) > 1]
cycles.sort(key=len, reverse=True)

for i, c in enumerate(cycles, 1):
    print(f"{i:>2}. size={len(c):>2}  {sorted(c)}")
Enter fullscreen mode Exit fullscreen mode

Output (trimmed):

 1. size=14  ['billing.models', 'billing.services', 'billing.tasks', ...]
 2. size= 6  ['accounts.permissions', 'accounts.models', 'audit.log', ...]
 3. size= 3  ['notifications.email', 'notifications.templates', 'billing.models']
...
37. size= 2  ['reports.csv', 'reports.formatters']
Enter fullscreen mode Exit fullscreen mode

37 strongly connected components. One monster with 14 modules, and a long tail of 2–3 module loops.

Step 2: Rank by blast radius, not by size

My instinct was "start with the giant one." Wrong. I asked Claude Code to score each cycle by:

  1. How many other modules import any member of the cycle (fan-in)
  2. How many lazy imports live inside it
  3. Whether any member is on a request hot path

Then I sorted ascending. Small, isolated cycles first. Each one I fixed shrank the big component a little, because several small cycles shared edges with the monster. By the time I reached cycle #1, it had dropped from 14 modules to 5.

💡 This was the single best decision of the project. Starting with the 14-module beast would've meant a 3,000-line PR nobody could review.

Step 3: One cycle per PR, with a fixed prompt

For each cycle, I gave Claude Code (v2.x at the time) the same structured prompt:

## Cycle #23
Members: notifications.email, notifications.templates, billing.models

Edges inside this cycle (from the graph, not a guess):
- notifications.email -> notifications.templates
- notifications.templates -> billing.models
- billing.models -> notifications.email   <-- suspicious

## Rules
- Do NOT add function-level imports to break the cycle.
- Prefer, in order: (1) move the shared thing to a lower layer,
  (2) depend on an interface/Protocol, (3) invert via a callback/event.
- Remove any existing "avoid circular import" lazy imports this unlocks.
- Touch only modules in this cycle plus at most one new module.
- Explain which edge you removed and why that edge was the wrong one.
Enter fullscreen mode Exit fullscreen mode

That last rule — "explain which edge was wrong" — turned out to be the most valuable line. It forced the agent to make an architectural claim I could agree or disagree with, instead of just making the error go away.

For cycle #23, the answer was obvious once stated: models should never know about email. billing.models was importing an email helper to send a receipt inside a save() override. The fix was to emit a domain event and let the notifications layer subscribe:

# billing/events.py  (new, lowest layer — imports nothing from billing)
from dataclasses import dataclass
from typing import Callable

@dataclass(frozen=True)
class InvoicePaid:
    invoice_id: int
    customer_id: int

_subscribers: list[Callable[[InvoicePaid], None]] = []

def subscribe(fn: Callable[[InvoicePaid], None]) -> None:
    _subscribers.append(fn)

def publish(event: InvoicePaid) -> None:
    for fn in _subscribers:
        fn(event)
Enter fullscreen mode Exit fullscreen mode
# notifications/email.py
from billing.events import InvoicePaid, subscribe

@subscribe
def send_receipt(event: InvoicePaid) -> None:
    ...
Enter fullscreen mode Exit fullscreen mode

The model now calls publish(InvoicePaid(...)) and has zero idea email exists. Edge removed, cycle gone.

Step 4: The three fix patterns that covered everything

Across all 37 cycles, every fix fell into one of three buckets:

Pattern Cycles Example
Move shared code down a layer 19 Constants and enums living in services moved to a types module
Depend on a Protocol instead of a class 11 Permission checks typed against HasOwner instead of importing Account
Invert with events/callbacks 7 The receipt example above
graph TD
    A[api] --> S[services]
    S --> M[models]
    M --> T[types / events]
    S --> T
    N[notifications] --> T
    N -.subscribes.-> T

Once the layers looked like this, the remaining "big" cycle mostly dissolved on its own.

Step 5: Lock it in with a CI contract

Fixing cycles is pointless if they come back in three weeks. I added import-linter with layer contracts:

[importlinter]
root_package = app

[importlinter:contract:layers]
name = Layered architecture
type = layers
layers =
    app.api
    app.services
    app.models
    app.types
Enter fullscreen mode Exit fullscreen mode

Plus a forbidden rule banning notifications from being imported by anything in models. This runs in about 4 seconds in CI and fails the build if anyone (human or agent) reintroduces an upward import.

I also added a tiny lint rule that flags new # avoid circular import comments. If you need one, the contract is telling you something.

The results

  • ✅ 37 → 0 strongly connected components among first-party modules
  • ✅ 214 → 9 function-level imports (the 9 remaining are legit optional-dependency imports)
  • ✅ 6 → 0 tests skipped for import-order flakiness
  • ✅ Cold start on the worst endpoint dropped from ~1.1 s to ~0.7 s
  • ✅ 31 PRs, average ~140 lines changed each, every one reviewed in under 15 minutes
  • ❌ One regression: a Celery task registration that silently relied on a side-effect import. Caught in staging, fixed in an hour.

Total: 8 working days, maybe 25 hours of my actual attention.

Lessons Learned

1. Give the agent the graph, not the traceback.
A traceback shows one edge. An agent fixing one edge will pick the cheapest hack — a lazy import. When I handed over the full strongly connected component with every edge listed, the fixes became architectural instead of cosmetic. Context shape matters more than prompt cleverness.

2. "Explain which edge was wrong" is the best review question I've found.
It turns a diff into a claim. I disagreed with the agent's choice on 5 of 37 cycles — and in every case, the disagreement was a genuinely useful design discussion, not a "the code is broken" fight. That's what I want from an AI pair.

3. Ban the lazy fix explicitly.
If the rules don't forbid function-level imports, the agent will use them, because they work. Agents optimize for "error gone." You have to define what "fixed" means.

4. Small cycles first. Always.
Big cycles are usually several small cycles sharing edges. Chip away at the edges and the monster shrinks. This also kept every PR reviewable, which matters more than speed when an agent is writing the code.

5. A fix without a guardrail is a loan, not a payment.
The import-linter contract took 20 minutes to set up and is the only reason I'm confident this work will still hold in six months.

What's Next

I'm planning to run the same graph-first approach on our frontend — a TypeScript app with madge reporting 22 cycles. My hunch is the pattern split will look different (way more "move shared types down" and fewer events), and I'm curious whether the "explain the wrong edge" rule holds up there too.

I'm also experimenting with having the agent propose layer contracts by reading the graph before any fixes, rather than me writing them by hand. Early results: it gets the layers right about 80% of the time and is weirdly opinionated about where utils belongs. (It's right. utils should not exist.)

Wrap-up

If you've got a pile of # avoid circular import comments, you don't have a few annoying bugs — you have an undocumented architecture. Map the graph, hand it to your agent with strict rules, go small-first, and lock it in with a contract.

👉 If this was useful, follow me here on Dev.to — I write build logs about shipping real refactors with Claude Code and autonomous coding agents, wins and failures included.

💬 And I'd love to hear: what's the worst circular import you've ever untangled? Drop it in the comments. 🚀

Top comments (0)