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.

19 min read
circular dependencydependency cyclestrongly connected componentsSCCimport cyclecyclic dependenciescircular import python

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 SCCDependency RelationshipsCognitive Load
22Low
36Medium
412High
520Very High
1090Nearly 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:

  1. Analyze entire repository
  2. Build complete dependency graph
  3. Find all cycles (direct, indirect, deep)
  4. Identify strongly connected components (SCCs)
  5. 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.