What Are Circular Dependencies? Cycles, SCCs, and How to Fix Them (Updated 10JUN26)
Learn what circular dependencies are, why strongly connected components matter, how dependency cycles form, and practical strategies for detecting, breaking, and preventing them.
You're three hours into debugging.
The error message does not make sense. The stack trace points to a file that works perfectly in isolation. You add a print statement. It never executes. You add another. Still nothing.
Then you notice the import chain:
auth.py imports user.py
user.py imports permissions.py
permissions.py imports auth.py
The files depend on each other.
What looked like a strange runtime bug is actually a structural problem: a circular dependency.
A circular dependency happens when two or more modules, packages, classes, or services depend on each other directly or indirectly. Instead of forming a one-way dependency chain, the graph loops back on itself.
A → B → C → A
Circular dependencies matter because they make code harder to understand, harder to test, harder to reuse, and harder to refactor. In dynamic languages, they can also create runtime failures. In larger systems, they often grow into strongly connected components: groups of files where every file can reach every other file through dependency paths.
This guide explains what circular dependencies are, why SCCs matter, how cycles form, how to detect them, and practical strategies for breaking them.
What Is a Circular Dependency?
A circular dependency exists when a dependency chain loops back to where it started.
The simplest form is a direct cycle:
A → B → A
For example:
# auth.py
from models.user import User
def authenticate(username, password):
user = User.query.filter_by(username=username).first()
...
# models/user.py
from auth import hash_password
class User:
def set_password(self, password):
self.password_hash = hash_password(password)
The graph looks like this:
auth.py → models/user.py → auth.py
Each module needs the other to load. They are locked together.
Circular dependencies also happen outside Python. A JavaScript example might look like this:
// auth.js
import { getUser } from "./user.js";
export function canLogin(id) {
return Boolean(getUser(id));
}
// user.js
import { canLogin } from "./auth.js";
export function getUser(id) {
if (!canLogin(id)) return null;
return { id };
}
The language-specific behavior differs, but the structural issue is the same:
auth.js → user.js → auth.js
The modules are no longer independent.
How Circular Dependencies Form
Circular dependencies almost never appear intentionally. They usually form through small, reasonable decisions.
A typical sequence looks like this:
auth.pyneeds user data, so it importsUser.- Later,
Userneeds password hashing, and the function happens to live inauth.py. - Someone imports
hash_passwordfromauth.py. - The cycle is born.
Each decision made sense locally. The problem only appears at the graph level.
This is why circular dependencies are easy to miss during feature work. A single import feels harmless. A single helper reuse feels practical. But over time, those imports become architecture.
Common causes include:
- shared utility modules that start importing business logic
- models that call service-layer code
- services that call each other directly
- framework routes imported by lower-level logic
- broad
__init__or barrel-file re-exports - type-only imports that accidentally become runtime imports
- convenience imports added without checking the existing dependency direction
By the time someone notices the cycle, the relationship may already be entrenched.
Why Circular Dependencies Are a Problem
Circular dependencies are not just a code smell. They create concrete maintenance problems.
They Destroy Local Reasoning
A module should be understandable through its public interface. You should not need to understand every consumer and every dependency just to change one function.
Circular dependencies break that.
If auth.py and user.py depend on each other, neither file has a clean boundary. To understand one, you need to understand the other. With indirect cycles, the problem gets worse:
auth → user → permissions → roles → auth
There is no obvious bottom. The reader keeps following dependencies and eventually lands back where they started.
This violates the principle that makes modular code useful: each module should hide details behind a stable boundary.
They Break Runtime Assumptions
In languages with import-time execution, circular dependencies can cause runtime failures.
Python is the classic example. Python executes modules top to bottom during import. If one module imports another before the first has finished initializing, Python may return a partially initialized module. That leads to errors such as:
ImportError: cannot import name 'User' from partially initialized module
JavaScript and TypeScript have their own cycle behavior. ES modules use live bindings, which can make some cycles work, but values can still be unavailable before initialization depending on the import pattern and execution order.
The specific error varies by language. The underlying problem is the same: the dependency graph has no clean starting point.
For a deeper Python-specific explanation, see Python Circular Imports: How to Find and Fix Circular Dependencies.
They Collapse Test Isolation
Unit tests rely on the ability to import and exercise modules independently.
With circular dependencies:
- testing
authmay requireuser - testing
usermay requirepermissions - testing
permissionsmay requireauth
The unit boundary collapses. Mocks become harder to place. Fixtures grow. Tests start acting like integration tests even when they are meant to isolate a small unit.
That does not mean the code is impossible to test, but it does mean the module boundaries are not doing their job.
They Make Refactoring Riskier
Circular dependencies lock structure in place.
Want to move User to a new package? You may need to untangle auth, permissions, roles, and service code first.
Want to reuse a helper in another context? You may discover that the helper pulls in half the application because it lives in a module that depends on higher-level code.
Cycles create invisible handcuffs. Change becomes expensive because nothing inside the cycle is truly independent.
They Hide in Plain Sight
Direct cycles are usually easy to spot:
A → B → A
But real systems often contain indirect cycles:
api → service → repository → model → api
or deep cycles:
routes → controller → service → validator → policy → middleware → routes
Each individual import may look reasonable. The cycle only appears when you view the whole graph.
Types of Circular Dependencies
Not all cycles are equally easy to detect or equally risky.
Direct Cycles
A direct cycle occurs when two modules import each other:
A → B → A
Example:
# a.py
from b import func_b
# b.py
from a import func_a
Direct cycles are usually the easiest to understand and fix.
Indirect Cycles
An indirect cycle involves three or more modules:
A → B → C → A
Example:
auth.py → user.py → permissions.py → auth.py
These are harder because no single file obviously imports its importer.
Deep Cycles
A deep cycle loops back after many layers:
A → B → C → D → E → F → A
These are common in mature systems with unclear boundaries. They are hard to detect manually because no developer is likely to follow every import path by hand.
Package-Level Cycles
Sometimes files are not individually cyclic, but packages are:
billing/* → payments/* → invoices/* → billing/*
Package-level cycles are architectural signals. They mean subsystems that appear separate are actually coupled.
Runtime or Service Cycles
Not all dependency cycles are import cycles. Services can also call each other cyclically:
OrderService → PaymentService → OrderService
Even if this does not break imports, it can still create architectural coupling. The same principles apply: if two services need to know each other’s internal behavior, the boundary is probably wrong.
What Is a Strongly Connected Component?
A circular dependency is often described as a loop. In real codebases, cycles overlap. One loop shares files with another loop. Several cycles merge.
At that point, the more useful concept is a strongly connected component.
A strongly connected component, or SCC, is a set of nodes in a directed graph where every node can reach every other node.
A ↔ B ↔ C ↔ D
In codebase terms, an SCC is a group of files, modules, or packages that are mutually reachable through dependency paths.
If five files form an SCC, they behave like one coupled unit even if they are physically split into five files.
Why SCCs Matter More Than Simple Cycles
A simple two-file cycle is a problem. A large SCC is a structural knot.
Inside an SCC:
- no file is fully independent
- testing one file may require the others
- refactoring one file may affect the group
- ownership becomes unclear
- dependency direction becomes difficult to explain
For example:
models/user.py
models/role.py
models/permission.py
models/group.py
If each file can reach the others directly or indirectly, then the model layer may look modular in the directory tree but behave as one coupled unit in the dependency graph.
The larger the SCC, the harder it is to reason about safely.
A rough way to think about the risk:
| Files in SCC | Structural risk |
|---|---|
| 2 | Usually manageable |
| 3–4 | Worth investigating |
| 5–9 | Significant refactoring risk |
| 10+ | Likely architectural knot |
This is not a universal rule, but it is a useful prioritization signal. A two-file SCC in stable code may not be urgent. A ten-file SCC in core business logic deserves attention.
How SCCs Form Over Time
SCCs usually form when multiple cycles overlap.
Start with one cycle:
auth → user → auth
Then user starts depending on permissions:
user → permissions
Then permissions depend on roles:
permissions → roles
Then roles need the current user:
roles → auth
Now the knot is larger:
auth → user → permissions → roles → auth
If other modules join the chain, the SCC expands. Over time, the system stops having small cycles and starts having coupled regions.
That is why SCC detection is more useful than simply listing individual cycles. It shows the real unit of coupling.
Common Patterns That Create Cycles
Pattern 1: The Quick Fix Import
A developer needs one function from another module:
from auth import generate_reset_token
The function is convenient. The import is easy. But the target module already imports the current module.
The fix solved the immediate problem and created a structural one.
Pattern 2: Feature Creep
A one-way dependency becomes bidirectional over time.
Initial state:
auth → user
Six months later:
user → auth
The second edge may have been added for password reset, verification emails, permissions, or session validation. The cycle came from feature accumulation, not deliberate design.
Pattern 3: Utility Module Sprawl
utils modules often become dependency traps.
A good utility is generic:
# utils/strings.py
def slugify(text: str) -> str:
return text.lower().replace(" ", "-")
A risky utility imports business logic:
# utils/helpers.py
from models.user import User
from auth import get_current_user
def get_user_display_name():
user = get_current_user()
return user.name
If business modules also import from utils.helpers, the utility module becomes a bridge for cycles.
If a utility imports domain objects, it may not be a utility. It may be misplaced business logic.
Pattern 4: Layer Violations
Layered systems rely on directional dependencies:
UI → API → Services → Domain → Infrastructure
Cycles appear when lower layers import higher layers:
Domain → API
Models → Services
Infrastructure → Domain configuration that imports infrastructure
When lower-level code knows too much about higher-level code, the direction collapses.
Pattern 5: Re-Exports and Barrel Files
Convenience re-exports can hide dependency edges.
Python example:
# models/__init__.py
from .user import User
from .order import Order
from .payment import Payment
TypeScript example:
// components/index.ts
export * from "./UserCard";
export * from "./BillingPanel";
export * from "./AdminTools";
Consumers import from the package root:
import { UserCard } from "@/components";
That may cause more modules to be loaded or connected than the developer expects. Barrel files are not always bad, but broad re-exports can make dependency structure harder to understand.
How to Detect Circular Dependencies
You have several options, from manual tracing to automated graph analysis.
Manual Tracing
For a small direct cycle, manual tracing can work:
- Open the file that fails.
- Follow its imports.
- Keep a list of visited modules.
- Stop when the chain returns to a module already in the list.
This works for:
- small codebases
- direct cycles
- obvious import errors
It breaks down for:
- indirect cycles
- package-level cycles
- deep dependency chains
- polyglot repositories
IDE Features
Some IDEs provide dependency analysis or import-cycle warnings. These can be useful during local development, especially for direct cycles or language-specific projects.
The limitation is that IDE features often focus on the current language, package, or project model. They may not expose the full structural graph across a larger repository.
Language-Specific Tools
Language-specific tools are often the best choice for CI enforcement.
For Python:
pydeps mypackage --show-cycles
For JavaScript and TypeScript:
madge --circular src/
or:
dependency-cruiser src --validate
For Go, import cycles are rejected by the compiler:
go build ./...
These tools are valuable because they can fail a build when cycles appear.
The limitation is that they are language-specific. In a polyglot repository, or when you want a broader architectural view, you may need a repository-level graph.
Dependency Graph Analysis
A dependency graph shows files or packages as nodes and dependency relationships as directed edges.
Once you have the graph, cycle detection becomes a graph problem:
- find direct cycles
- find indirect cycles
- find strongly connected components
- sort SCCs by size and location
- identify which cycles intersect core business logic
This is where tools like PViz are useful. The hosted PViz service analyzes a repository and reports its dependency structure, including cycles and strongly connected components, in a bundle that can be inspected directly or used in custom workflows.
The most useful output is not just “there is a cycle.” It is:
- which files are involved
- whether the cycle is direct or indirect
- whether the files form a larger SCC
- whether the SCC sits in core code or peripheral code
- which dependency edges are candidates to remove
How to Break Circular Dependencies
Once you find a cycle, the goal is to remove at least one edge from the loop.
There are several common strategies.
Strategy 1: Extract Shared Logic
If two modules import each other because they share one function, move that function to a neutral module.
Problem:
# auth.py
from models.user import User
def authenticate(username, password):
user = User.query.filter_by(username=username).first()
...
# models/user.py
from auth import hash_password
class User:
def set_password(self, password):
self.password_hash = hash_password(password)
Cycle:
auth.py → models/user.py → auth.py
Fix:
# security/passwords.py
def hash_password(password: str) -> str:
...
def verify_password(password: str, hashed: str) -> bool:
...
Then:
# auth.py
from models.user import User
from security.passwords import verify_password
# models/user.py
from security.passwords import hash_password
Result:
auth.py → models/user.py
auth.py → security/passwords.py
models/user.py → security/passwords.py
The shared logic has a stable home. Both modules depend on it, but not on each other.
Strategy 2: Reverse the Dependency
Sometimes the import points the wrong way.
Problem:
# models/user.py
from auth import send_verification_email
class User:
def send_verification(self):
send_verification_email(self.email)
Why is the model calling authentication or email workflow logic?
A cleaner direction is:
# models/user.py
class User:
email: str
# auth.py
from models.user import User
def send_verification_email(user: User):
...
The dependency becomes one-way:
auth.py → models/user.py
A useful rule of thumb:
higher-level workflow code may import lower-level domain/data code
lower-level domain/data code should not import higher-level workflow code
If a lower layer needs to call an upper layer, consider moving the behavior upward or introducing an abstraction.
Strategy 3: Introduce an Interface or Protocol
If one module depends on a concrete class but only needs a small behavior, depend on an abstraction instead.
Problem:
# email_service.py
from models.user import User
def send_email(user: User):
send(user.email)
# models/user.py
from email_service import send_email
class User:
def notify(self):
send_email(self)
Fix:
# shared/protocols.py
from typing import Protocol
class EmailRecipient(Protocol):
email: str
# email_service.py
from shared.protocols import EmailRecipient
def send_email(recipient: EmailRecipient):
send(recipient.email)
# models/user.py
class User:
email: str
Now the email service depends on a protocol, not the concrete User model. The model does not need to import the email service.
In TypeScript, the same idea might use an interface:
export interface EmailRecipient {
email: string;
}
Both sides can depend on the interface instead of each other.
Strategy 4: Add an Orchestration Layer
Service cycles often mean workflow logic is in the wrong place.
Problem:
OrderService → PaymentService → OrderService
Instead of having the services call each other directly, create a higher-level orchestrator:
# 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.mark_paid(order_id)
Now:
CheckoutService → OrderService
CheckoutService → PaymentService
The lower-level services no longer import each other. The workflow lives in a module whose job is coordination.
This is often clearer than forcing dependency injection into a design that really needs a separate workflow boundary.
Strategy 5: Use Events
Events are useful when one part of the system needs to react to another without a direct import.
Instead of:
UserService → EmailService
EmailService → UserService
use:
UserService → EventBus
EmailHandler → EventBus
Example:
# user_service.py
from events import publish
def register_user(user):
save_user(user)
publish(UserRegistered(user.id))
# email_handlers.py
from events import subscribe
@subscribe(UserRegistered)
def send_welcome_email(event):
...
Events reduce direct coupling. They are not free — they introduce indirection — but they are valuable when the relationship is genuinely reactive.
Strategy 6: Split Utility Modules
If a utility module imports business logic, split it.
Problem:
# utils/helpers.py
from models.user import User
from billing.invoice import Invoice
def format_user_invoice(user: User, invoice: Invoice) -> str:
...
If models or billing also import from utils.helpers, cycles become likely.
Fix:
utils/strings.py
utils/dates.py
billing/formatting.py
users/display.py
Keep generic utilities generic. Move domain-specific helpers closer to the domain they serve.
Strategy 7: Use Lazy Imports Carefully
A lazy import can avoid an import-time failure:
def process_order(order_id):
from payment import charge_card
charge_card(order_id)
This works because the import happens when the function runs, not when the module loads.
But lazy imports should usually be treated as temporary or intentional exceptions. They can:
- hide dependencies from readers
- hide dependencies from tools
- move failures from startup to runtime
- make performance less predictable
- preserve the design problem instead of fixing it
Lazy imports are acceptable for optional dependencies, performance-sensitive imports, or narrow framework constraints. They are less acceptable when used to hide a boundary problem.
Breaking Strongly Connected Components
SCCs require a slightly different approach than simple cycles.
If four files all depend on each other, removing one import may not be enough. You need to shrink the knot.
Example:
user.py ↔ role.py ↔ permission.py ↔ group.py
Start by mapping the edges:
user.py → role.py
user.py → group.py
role.py → permission.py
role.py → user.py
permission.py → group.py
group.py → user.py
Then classify the edges:
- Which dependencies are fundamental?
- Which are convenience imports?
- Which are type-only?
- Which are query helpers?
- Which point the wrong direction?
- Which could move to a neutral module?
Break the weakest edges first.
For example, if role.py → user.py exists only to list users with a role, move that query into a repository, service, or runtime lookup. If user.py → group.py exists only for type annotations, move it behind TYPE_CHECKING or use a string/forward reference.
Sometimes the honest answer is that several files form one conceptual unit. If breaking the SCC would create worse design, document the SCC and treat it as a unit:
- test it together
- refactor it together
- assign ownership clearly
- avoid pretending the files are independent
The goal is better boundaries, not artificial purity.
Preventing Circular Dependencies
Prevention is easier than cleanup.
Rule 1: Make Dependency Direction Explicit
Define the intended direction:
UI → API → Services → Domain → Infrastructure
or:
Routes → Use Cases → Domain → Adapters
The exact architecture does not matter as much as the rule: dependencies should mostly flow one way.
When a lower layer imports a higher layer, stop and ask why.
Rule 2: Keep Utilities Pure
Utilities should not know about application-specific models, services, or routes.
If a helper needs domain objects, it may belong in the domain.
Rule 3: Review Imports During Code Review
Every new import is a new edge in the dependency graph.
When reviewing a PR, ask:
- Does this import point in the intended direction?
- Does the target already import this module directly or indirectly?
- Is this dependency type-only?
- Would a neutral module or interface be cleaner?
- Is this utility becoming domain-specific?
This is a small habit that prevents large knots.
Rule 4: Watch Re-Exports
Barrel files and package __init__ files can be useful, but broad re-exports can obscure architecture.
Prefer explicit imports for internal application code when dependency clarity matters.
Rule 5: Automate Where Possible
For strict enforcement, use language-specific tools in CI:
dependency-cruiserfor JavaScript/TypeScriptmadgefor JavaScript/TypeScriptpydepsor custom graph checks for Python- compiler checks where the language enforces acyclic imports
For architectural review, use dependency graph visualization to inspect SCCs and high-risk cycles.
The goal is not to block every cycle forever. The goal is to make cycles intentional, visible, and rare.
What to Fix First
Not every cycle deserves the same urgency.
Prioritize cycles and SCCs that:
- sit in core business logic
- involve high-churn files
- block testing or mocking
- cross intended architectural layers
- include many files
- confuse new developers
- cause runtime import errors
- make refactoring risky
A small cycle in a stable script may be tolerable. A large SCC through api, services, and models is a structural problem.
Useful first steps:
- Generate the dependency graph.
- Sort SCCs by size.
- Identify SCCs in core code.
- Pick one edge to remove.
- Re-run the graph.
- Repeat.
Breaking cycles is often incremental. You do not have to untangle the whole system in one change.
Conclusion
Circular dependencies are invisible handcuffs.
They usually form through reasonable local decisions:
- one convenient import
- one shared helper
- one shortcut around a boundary
- one re-export that hides too much
Left unchecked, they make code harder to reason about, harder to test, harder to reuse, and harder to refactor.
Strongly connected components reveal the deeper issue. Sometimes the problem is not a single file. It is a group of files that behave like one tangled unit.
To detect cycles:
- inspect import chains for small cases
- use language-specific tools for local enforcement
- use dependency graphs to find SCCs and architectural knots
To break cycles:
- extract shared logic
- reverse incorrect dependencies
- introduce interfaces or protocols
- add orchestration layers
- use events where appropriate
- split utility modules
- use lazy imports only deliberately
To prevent cycles:
- define dependency direction
- keep utilities pure
- review imports as graph edges
- watch re-exports
- automate cycle detection where it matters
The code did not need to be circular. It needed clearer boundaries.
Fix the structure, and the rest gets easier.
Want to Find Cycles and SCCs in Your Own Codebase?
Generate a dependency graph and look for strongly connected components. PViz can report cycles and dependency knots for supported repositories.
For Python-specific import-cycle behavior, read Python Circular Imports: How to Find and Fix Circular Dependencies.
Related Reading
Try PViz on your own codebase
Get dependency graphs, coupling signals, and a compressed bundle ready for your LLM, for GitHub repositories, free with an account.