Circular Dependencies & Strongly Connected Components: What They Are — and Why They Break Your Code
Circular dependencies break your code in subtle ways. Learn what they are, how to detect them (including SCCs), and 5 proven strategies to break dependency cycles in Python, JavaScript, and more.
You're three hours into debugging.
The error message doesn't 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 it.
auth.py imports user.py.
user.py imports auth.py.
They depend on each other.
And suddenly, the mysterious behavior makes perfect sense.
What Are Circular Dependencies?
A circular dependency exists when two or more modules cannot exist without each other.
A Simple 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 dependency graph looks like this:
auth.py → models/user.py → auth.py
Neither module can be imported without the other. They're locked together.
How This Happens
Circular dependencies almost never appear intentionally.
They form incrementally.
You're working on authentication. You need user data. So auth.py imports User.
Later, you're working on the user model. You need to hash passwords. The hashing function lives in auth.py. So you import it.
Each decision made sense in isolation.
Together, they formed a cycle.
Why developers create cycles:
It's never intentional. It's always incremental:
- "Just one import" (seems harmless)
- "I'll refactor later" (never happens)
- "This makes the code simpler right now" (creates debt)
- "Everyone else imports this way" (pattern spreads)
By the time someone notices, the cycle is entrenched.
Why Circular Dependencies Are a Problem
Circular dependencies aren't just a code smell. They create concrete, compounding problems.
They Destroy Local Reasoning
Try explaining how auth.py works without explaining user.py—you can't.
Try explaining user.py without auth.py—also impossible.
There's no starting point. No bottom. Just a loop. Understanding requires loading multiple files into your head simultaneously.
This is the fundamental problem with circular dependencies.
They violate the core principle that made modular programming work in the first place: information hiding.
Modules were supposed to be black boxes. You shouldn't need to understand implementation to use the interface.
But with circular dependencies, the black box is broken. Both sides can see inside each other. Both sides depend on internal details. Neither can change independently.
The promise of modularity: gone.
They Break Runtime Assumptions
In Python, imports execute top-to-bottom.
If auth.py imports User while Python is still executing user.py, you'll see errors like:
ImportError: cannot import name 'User' from partially initialized module
Even worse, the import may succeed—but the object you're using doesn't exist yet. Bugs appear non-deterministic.
They Collapse Test Isolation
Unit tests rely on the idea that modules can be imported and mocked independently.
With circular dependencies:
- Testing auth.py requires user.py
- Testing user.py requires auth.py
Your "unit" tests become integration tests whether you want them to or not.
They Lock Structure in Place
Want to move User to a different package?
You can't—not without untangling auth.py.
Want to reuse password hashing elsewhere?
You can't—it's trapped inside a cycle.
Circular dependencies create invisible handcuffs. Change becomes expensive.
They Hide in Plain Sight
In small codebases, cycles are rare.
In large codebases, they accumulate quietly:
auth → user → permissions → role → auth
billing → payment → invoice → billing
api → service → repository → models → api
Each step seemed reasonable. The system, over time, became rigid.
Types of Circular Dependencies
Not all cycles look the same.
Direct Cycles (Obvious)
Two modules import each other:
# A → B → A
# a.py
from b import func_b
# b.py
from a import func_a
These are usually easy to spot and fix.
Indirect Cycles (Subtle)
Three or more modules form a loop:
A → B → C → A
# auth.py
from user import User
# user.py
from permissions import check_permission
# permissions.py
from auth import get_current_user
You can read each file independently and miss the problem entirely.
Deep Cycles (Hidden)
Long chains that eventually loop back:
A → B → C → D → E → F → A
Example:
api/routes.py →
controllers/auth.py →
services/user.py →
models/permission.py →
utils/access.py →
api/middleware.py →
api/routes.py
Seven files. Six layers deep. One cycle.
These are nearly impossible to detect manually.
From Cycles to Knots: Strongly Connected Components
Up to this point, we've treated circular dependencies as loops—something that starts at one file and eventually returns.
But in real systems, cycles rarely stay isolated.
They overlap. They share files. One loop weaves into another.
Eventually, you're no longer dealing with "a cycle."
You're dealing with a knot.
What Is a Strongly Connected Component (SCC)?
In graph theory, a strongly connected component (SCC) is a set of nodes where every node can reach every other node.
A ↔ B ↔ C ↔ D
Every file depends on every other file—directly or indirectly.
An SCC is the real unit of coupling in a codebase.
If five files form an SCC, refactoring one requires understanding all five. They behave like a single module, even if they're split across files.
Why SCCs Matter More Than Simple Cycles
A simple cycle is a problem.
An SCC is a system.
You cannot:
- Understand one file independently
- Test one file independently
- Refactor one file independently
- Reuse one file independently
In real codebases:
models/
user.py ↔ role.py ↔ permission.py ↔ group.py
Four files. One inseparable unit.
Why SCCs are worse than simple cycles:
A simple cycle (A ↔ B) has 2 files locked together.
An SCC with 4 files means:
- 6 possible dependency paths (every pair can reach each other)
- Testing complexity grows exponentially
- Any change requires understanding all 4 files simultaneously
- Refactoring requires coordinating changes across all files
The math of SCC complexity:
| Files in SCC | Dependency Relationships | Cognitive Load |
|---|---|---|
| 2 | 2 | Low |
| 3 | 6 | Medium |
| 4 | 12 | High |
| 5 | 20 | Very High |
| 10 | 90 | Nearly Impossible |
Note: For a fully connected SCC with n files, there are n(n-1) possible directed dependency paths. Complexity grows quadratically, making large SCCs nearly impossible to refactor safely.
Beyond 5 files in an SCC, refactoring becomes nearly impossible without breaking everything first.
How Circular Dependencies Form Over Time
Circular dependencies accumulate through small, reasonable decisions.
Pattern 1: The "Quick Fix"
Scenario:
You're implementing password reset. You need to generate a token. Token generation lives in auth.py.
But auth.py already imports User from models/user.py.
So now models/user.py needs something from auth.py.
The thought process:
"I just need this one function. I'll add a quick import. I'll refactor later."
Later never comes. The cycle persists.
Pattern 2: Feature Creep
Start:
# auth.py
from models.user import User
def login(username, password):
user = User.query.filter_by(username=username).first()
# ...
Six months later:
# models/user.py
from auth import send_verification_email
from auth import check_password_strength
from auth import generate_reset_token
from auth import validate_session
What started as a one-way dependency became bidirectional through incremental feature additions.
Pattern 3: The "Utils" Trap
Common pattern:
# utils/helpers.py
from models.user import User
from models.payment import Payment
from auth import get_current_user
utils becomes a grab bag. It imports from everywhere.
But then other files import from utils:
# auth.py
from utils.helpers import format_date
# models/user.py
from utils.helpers import validate_email
Now you have:
auth → utils → models/user → utils → auth
Circular dependency through the utility module.
Pattern 4: Shared State
Scenario:
Two modules need to share configuration or state.
# config.py
from database import get_connection_string
# database.py
from config import DATABASE_URL
Both need information from each other.
Result: circular dependency.
How to Detect Circular Dependencies
You have options, from manual to automated.
Manual Tracing
For simple cases:
Open a file. Follow the imports. Keep a list. See if you loop back.
This works for:
- Small codebases (< 50 files)
- Direct cycles (A → B → A)
This fails for:
- Indirect cycles (A → B → C → A)
- Deep cycles (7+ files in the loop)
- Large codebases (you'll miss things)
IDE Features
Some IDEs show import cycles:
- PyCharm: "Dependency Analysis"
- IntelliJ: "Analyze Dependencies"
- VSCode: (via extensions like "Import Cost")
Pros:
- Built-in
- Visual highlighting
Cons:
- Often limited to direct cycles
- Doesn't catch all patterns
- Requires manual inspection
Language-Specific Tools
Python:
# pydeps finds cycles
pydeps mypackage --show-cycles
JavaScript:
# madge detects circular dependencies
madge --circular src/
Go:
# Go's compiler refuses to compile circular imports
go build
# Returns error if cycles exist
Pros:
- Automated
- Catches most cycles
Cons:
- Language-specific (useless for polyglot repos)
- May miss complex SCCs
- Requires setup
Automated Analysis Tools
Tools like PViz detect circular dependencies automatically across any language:
- Analyze entire repository
- Build complete dependency graph
- Find all cycles (direct, indirect, deep)
- Identify strongly connected components (SCCs)
- Show you exactly which files form each cycle
Example PViz output:
Analyzing repository: payments-api
Circular dependencies found: 3
Cycle 1 (direct, 2 files):
auth/service.py ↔ models/user.py
Cycle 2 (indirect, 3 files):
api/routes.py → controllers/billing.py →
services/payment.py → api/routes.py
Cycle 3 (deep, 4 files):
models/user.py ↔ models/role.py ↔
models/permission.py ↔ models/group.py
Strongly Connected Components: 1
Component: models/user.py, models/role.py,
models/permission.py, models/group.py
(All 4 files can reach each other)
Risk level: High
Recommendation: Break into smaller units or extract shared base
Now you know:
- How many cycles exist
- Which files are involved
- How complex each cycle is
- Which cycles form SCCs
How to Break Circular Dependencies
Once you've found a cycle, you need to break it.
Here are the proven patterns.
Strategy 1: Extract Shared Logic
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)
The cycle:
auth.py → models/user.py → auth.py
Solution:
Create a third module for the shared functionality:
# utils/crypto.py
def hash_password(password):
import bcrypt
return bcrypt.hashpw(password.encode(), bcrypt.gensalt())
def verify_password(password, hashed):
import bcrypt
return bcrypt.checkpw(password.encode(), hashed)
Update auth:
# auth.py
from models.user import User
from utils.crypto import verify_password
def authenticate(username, password):
user = User.query.filter_by(username=username).first()
if user and verify_password(password, user.password_hash):
return user
return None
Update user model:
# models/user.py
from utils.crypto import hash_password
class User:
def set_password(self, password):
self.password_hash = hash_password(password)
Result:
auth.py → models/user.py
auth.py → utils/crypto.py
models/user.py → utils/crypto.py
No cycle. Both depend on crypto, but not on each other.
Strategy 2: Reverse the Dependency
Problem:
# models/user.py
from auth import send_verification_email
class User:
def send_verification(self):
send_verification_email(self.email)
Question: Why is the model calling auth logic?
Solution:
Flip it. Auth should call the model, not vice versa:
# models/user.py
class User:
# No import of auth needed
pass
# auth.py
from models.user import User
def send_verification_email(user):
# send email to user.email
pass
Result:
auth.py → models/user.py
One-way dependency. No cycle.
Rule of thumb:
Lower-level modules (models, data) should not import higher-level modules (controllers, services).
Dependencies should flow downward:
Good:
UI → Controllers → Services → Models → Database
Bad:
Models → Services # Violates the rule
Database → Models # Violates the rule
If a model needs service logic, you're probably doing it wrong.
Strategy 3: Use Dependency Injection
Problem:
# service.py
from repository import UserRepository
class UserService:
def __init__(self):
self.repo = UserRepository()
# repository.py
from service import UserService
class UserRepository:
def __init__(self):
self.service = UserService()
Solution:
Inject dependencies instead of importing them:
# service.py
class UserService:
def __init__(self, repository):
self.repo = repository
# repository.py
class UserRepository:
def __init__(self, service):
self.service = service
# main.py
from service import UserService
from repository import UserRepository
# Break the cycle at composition time
# Note: This is a simplified example. In production, use a DI container.
repo = UserRepository(service=None)
service = UserService(repository=repo)
# Better approach: reconsider if both truly need references to each other
# Often this indicates they should be a single class or the design needs rethinking
Even better—reconsider the design:
If repository and service need each other, they're probably the same abstraction split incorrectly.
Result:
Neither service.py nor repository.py import each other.
The cycle is broken.
Strategy 4: Introduce an Interface/Protocol
Problem:
# email.py
from models.user import User
def send_email(user: User):
# Send email to user.email
pass
# models/user.py
from email import send_email
class User:
def notify(self):
send_email(self)
Solution:
Define a protocol/interface:
# protocols.py
from typing import Protocol
class EmailRecipient(Protocol):
email: str
name: str
# email.py
from protocols import EmailRecipient
def send_email(recipient: EmailRecipient):
# Send email to recipient.email
pass
# models/user.py
# No import of email.py needed
class User:
email: str
name: str
# User satisfies EmailRecipient protocol implicitly
Why this breaks the cycle:
- email.py depends on the protocol, not the concrete User class
- models/user.py doesn't need to import anything from email.py
- User satisfies EmailRecipient through structural subtyping (duck typing)
- This is dependency inversion: both depend on an abstraction, not on each other
Result:
Both depend on protocols, but not on each other.
Strategy 5: Lazy Imports (Last Resort)
Problem:
# a.py
from b import func_b
def my_function():
func_b()
# b.py
from a import func_a
def my_function():
func_a()
Solution (Python):
Import inside the function instead of at the top:
# a.py
def my_function():
from b import func_b # Import here, not at top
func_b()
# b.py
def my_function():
from a import func_a # Import here, not at top
func_a()
Why this works:
The import happens at runtime, after both modules are loaded.
Why this is bad:
- Hides the dependency
- Harder to understand
- Performance cost (imports on every call)
- Doesn't actually fix the design problem
Only use lazy imports as a temporary workaround while you refactor properly.
Real Example: Breaking a Circular Dependency
Let's walk through a complete refactoring.
Starting State
# auth/service.py
from models.user import User, UserRole
def authenticate(username, password):
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
return user
return None
# models/user.py
from auth.service import hash_password
class User:
username: str
password_hash: str
def check_password(self, password):
return self.password_hash == hash_password(password)
def set_password(self, password):
self.password_hash = hash_password(password)
The cycle:
auth/service.py → models/user.py → auth/service.py
The problem:
- auth/service needs User to authenticate
- models/user needs hash_password to set passwords
Step 1: Extract Crypto Logic
Create a new module for password operations:
# utils/crypto.py
import bcrypt
def hash_password(password: str) -> str:
"""Hash a password using bcrypt."""
return bcrypt.hashpw(
password.encode('utf-8'),
bcrypt.gensalt()
).decode('utf-8')
def verify_password(password: str, hashed: str) -> bool:
"""Verify a password against its hash."""
return bcrypt.checkpw(
password.encode('utf-8'),
hashed.encode('utf-8')
)
Step 2: Update User Model
# models/user.py
from utils.crypto import hash_password, verify_password
class User:
username: str
password_hash: str
def check_password(self, password: str) -> bool:
return verify_password(password, self.password_hash)
def set_password(self, password: str):
self.password_hash = hash_password(password)
Step 3: Update Auth Service
# auth/service.py
from models.user import User
# No longer imports hash_password
def authenticate(username: str, password: str):
user = User.query.filter_by(username=username).first()
if user and user.check_password(password): # Uses User's method
return user
return None
Final Result
New dependency structure:
auth/service.py → models/user.py
models/user.py → utils/crypto.py
No cycle.
Both modules work independently:
- auth/service can be tested by mocking User
- models/user can be tested by mocking crypto operations
- utils/crypto has no dependencies
Clean separation of concerns.
Verify the fix:
# Run dependency analysis
pydeps auth --show-deps
# Should show:
# auth/service.py → models/user.py ✓
# models/user.py → utils/crypto.py ✓
# No cycles detected ✓
Breaking Strongly Connected Components
SCCs require a different approach than simple cycles.
When you have multiple files all depending on each other, breaking the knot is harder.
Example SCC
# models/user.py
from models.role import Role
from models.group import Group
class User:
roles: list[Role]
groups: list[Group]
# models/role.py
from models.permission import Permission
from models.user import User
class Role:
permissions: list[Permission]
users: list[User]
# models/permission.py
from models.group import Group
class Permission:
groups: list[Group]
# models/group.py
from models.user import User
class Group:
members: list[User]
The knot:
user.py ↔ role.py ↔ permission.py ↔ group.py
All four files can reach each other. Any change requires understanding all four.
Step 1: Understand Why the Knot Exists
Map out the actual dependencies:
- user.py imports role.py (to check user roles)
- user.py imports group.py (to check user groups)
- role.py imports permission.py (to check role permissions)
- role.py imports user.py (to list users with role)
- permission.py imports group.py (to check group permissions)
- group.py imports user.py (to list group members)
Six bidirectional dependencies. Complete knot.
Step 2: Identify Weak Links
Which dependencies are incidental vs fundamental?
Fundamental:
- user.py → role.py (users need roles)
- role.py → permission.py (roles need permissions)
Incidental:
- role.py → user.py (just for listing users)
- group.py → user.py (just for listing members)
Strategy: Break the weak links first.
Step 3: Extract Shared Concepts
Often, SCCs form because shared logic is duplicated across files.
Solution:
# models/base.py
from datetime import datetime
class Entity:
"""Base class for all domain entities."""
id: int
created_at: datetime
updated_at: datetime
# models/user.py
from models.base import Entity
# Use TYPE_CHECKING to avoid runtime import
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from models.role import Role
from models.group import Group
class User(Entity):
username: str
# Use string annotations to avoid importing at runtime
roles: list['Role']
groups: list['Group']
# models/role.py
from models.base import Entity
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from models.permission import Permission
class Role(Entity):
name: str
permissions: list['Permission']
def get_users(self):
# Query users at runtime instead of storing reference
from models.user import User
return User.query.filter(User.roles.contains(self))
Result:
user.py → base.py
role.py → base.py
permission.py → base.py
group.py → base.py
The SCC is broken. All files depend on base, but not on each other at import time.
Type hints use forward references ('Role' instead of Role) to avoid circular imports.
Step 4: Accept Necessary Coupling
Sometimes, four models genuinely belong together.
If breaking the cycle requires worse design (artificial layers, god modules), don't do it.
Instead, treat the SCC as a single unit:
- Test them together
- Document them together
- Refactor them together
- Deploy them together
It's not ideal, but it's honest.
Better to have one acknowledged 4-file unit than four "independent" files with hidden coupling.
Preventing Circular Dependencies
Prevention is easier than cure.
Rule 1: Dependencies Flow Downward
Good:
UI → Controllers → Services → Models → Database
Each layer depends on the layer below. Never upward.
Bad:
Models → Services # Violates the rule
Database → Models # Violates the rule
If a model needs service logic, you're probably doing it wrong.
Enforce with:
- Architecture tests
- Linting rules
- Code review guidelines
Rule 2: Keep Utilities Pure
If utils/ starts importing from your business logic, you've gone wrong.
Good utils:
# utils/strings.py
def slugify(text: str) -> str:
"""Convert text to URL-safe slug."""
return text.lower().replace(' ', '-')
No business logic. No imports from other parts of the app.
Bad utils:
# utils/helpers.py
from models.user import User
from auth import get_current_user
def get_user_display_name():
user = get_current_user()
return f"{user.first_name} {user.last_name}"
This isn't a utility. This is business logic pretending to be a utility.
Rule 3: Watch Imports During Code Review
Before merging a PR that adds an import, ask:
"Does this create a cycle?"
If the new import goes to a module that already imports from you (directly or indirectly), stop.
Find another way.
In code review:
# models/user.py
+ from auth import send_verification_email
class User:
def notify(self):
+ send_verification_email(self)
Reviewer should ask:
"Does auth already import User?"
If yes → this PR creates a cycle → reject and suggest alternatives.
Rule 4: Use Dependency Graphs Regularly
Run dependency analysis:
- After major features
- During code review
- As part of CI/CD
Catch cycles before they merge.
Example CI integration:
# .github/workflows/check-dependencies.yml
name: Check for Circular Dependencies
on: [pull_request]
jobs:
check-cycles:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Check for cycles
run: |
# Using automated analysis tools
pviz analyze --check-cycles ${{ github.repository }}
# Fails build if cycles detected
Tools that provide automated analysis make this straightforward to implement.
Conclusion
Circular dependencies are invisible handcuffs.
They don't appear overnight. They accumulate through small, reasonable decisions.
Left unchecked, they:
- Destroy local reasoning
- Break test isolation
- Prevent refactoring
- Turn change into risk
Strongly connected components reveal the deeper truth: sometimes the problem isn't a file—it's a structure.
To detect them:
- Use dependency graphs
- Run automated analysis regularly
- Watch for import errors as early warnings
To break them:
- Extract shared logic into third modules
- Reverse dependencies (lower layers don't import upper layers)
- Use dependency injection
- Introduce interfaces/protocols
- (Lazy imports only as temporary workaround)
To prevent them:
- Dependencies flow downward
- Keep utilities pure
- Review imports carefully
- Check dependency graphs in CI
The code didn't need to be circular.
It just needed better boundaries.
Fix the structure, and the rest gets easier.
Want to find cycles and SCCs in your own codebase?
Try PViz at pvizgenerator.com—it automatically detects circular dependencies, identifies strongly connected components, and shows you exactly which files form each dependency knot across any language.
Related reading:
- What is a Dependency Graph?
- How Developers Try to Understand New Codebases
- Code Coupling: How to Measure and Reduce It — coming soon
Try PViz on Your Codebase
Get instant dependency graphs, architectural insights, and coupling analysis for any GitHub repository.