DEV Community

Cover image for The Circular Import Problem: Breaking Dependency Cycles
Aaron Rose
Aaron Rose

Posted on

The Circular Import Problem: Breaking Dependency Cycles

Timothy's library system had grown elegantly. The Book class needed to access database functions. The database module needed to import Book to save records. He added the imports, ran the code, and Python exploded with a cryptic error: "ImportError: cannot import name 'Book' from partially initialized module."

The circular dependency:

# library/models.py
from library.database import save_to_db

class Book:
    def __init__(self, title):
        self.title = title

    def save(self):
        save_to_db(self)

# library/database.py
from library.models import Book

def save_to_db(book):
    print(f"Saving {book.title}")

# library/main.py
from library.models import Book

book = Book("Dune")
book.save()
# ImportError: cannot import name 'Book' from partially initialized module 'library.models'
Enter fullscreen mode Exit fullscreen mode

The actual error traceback:

# Running: python library/main.py

Traceback (most recent call last):
  File "/path/library/main.py", line 1, in <module>
    from library.models import Book
  File "/path/library/models.py", line 1, in <module>
    from library.database import save_to_db
  File "/path/library/database.py", line 1, in <module>
    from library.models import Book
ImportError: cannot import name 'Book' from partially initialized module 'library.models' 
(most likely due to a circular import) (/path/library/models.py)

# Key indicators of circular import:
# 1. "partially initialized module" in error message
# 2. "(most likely due to a circular import)" hint
# 3. The SAME module appears TWICE in the traceback
#    - Line 2: importing FROM library.models
#    - Line 6: importing library.models again
# 4. Traceback shows the cycle: main → models → database → models
Enter fullscreen mode Exit fullscreen mode

Margaret found him staring at the error. "Your modules are chasing each other's tails," she observed. "Come to the Circular Import Problem—where we'll learn to break the cycle."

Understanding the Circular Import

She showed him what Python sees:

The execution sequence:

# When you run: from library.models import Book

# Step 1: Python starts importing library.models
#   - Creates empty module object for library.models
#   - Starts executing models.py

# Step 2: models.py line 1 executes
from library.database import save_to_db
#   - Python starts importing library.database
#   - Creates empty module object for library.database
#   - Starts executing database.py

# Step 3: database.py line 1 executes
from library.models import Book
#   - Python needs to import library.models
#   - But library.models is ALREADY BEING IMPORTED!
#   - It's not finished yet - Book doesn't exist yet
#   - ImportError: cannot import name 'Book'

# The cycle:
# models.py imports database.py
# database.py imports models.py
# Neither can finish!
Enter fullscreen mode Exit fullscreen mode

What's in sys.modules During the Import?

Timothy asked how Python tracks this. Margaret showed him:

The module registry state:

import sys

# During the circular import, checking sys.modules:

# After Step 1 (models.py starts):
print('library.models' in sys.modules)  # True
print(sys.modules['library.models'])    # <module 'library.models'> (exists!)
print(hasattr(sys.modules['library.models'], 'Book'))  # False (not defined yet!)

# The module object exists in sys.modules immediately
# But its contents (Book class) don't exist until execution finishes

# When database.py tries: from library.models import Book
# Python finds library.models in sys.modules ✓
# Python looks for 'Book' attribute in that module ✗
# Book doesn't exist yet!
# ImportError: cannot import name 'Book'

# This is why "import library.models" works:
import library.models  # Works - module object exists
library.models.Book    # Fails - Book not defined yet

# But "from library.models import Book" fails:
from library.models import Book  # Fails - Book must exist NOW
Enter fullscreen mode Exit fullscreen mode

"Python imports modules only once," Margaret explained. "When database.py tries to import from models.py, Python sees it's already importing models.py. It returns the partially initialized module—which doesn't have Book defined yet. The circular dependency breaks the import."

Visualizing the Cycle

The import chain:

main.py
  └─> imports models.py
        └─> imports database.py
              └─> imports models.py (CYCLE!)
                    └─> models.py is still initializing
                          └─> Book class not defined yet
                                └─> ImportError!

# The problem: Neither module can finish
# before the other starts needing it
Enter fullscreen mode Exit fullscreen mode

Solution 1: Restructure to Remove the Cycle

Timothy learned the cleanest solution: break the dependency.

Extracting shared dependencies:

# BEFORE (circular):
# models.py imports database.py
# database.py imports models.py

# AFTER (no cycle):
# Create a shared module that both can import

# library/models.py
class Book:
    def __init__(self, title):
        self.title = title

    def save(self):
        from library.database import save_to_db
        save_to_db(self)  # Import happens inside method

# library/database.py
# No longer imports Book!
def save_to_db(book):  # Just accepts any object with .title
    print(f"Saving {book.title}")

# Now the cycle is broken:
# models.py doesn't import database at module level
# database.py doesn't import models at all
Enter fullscreen mode Exit fullscreen mode

"The database module doesn't actually need to know about Book," Margaret noted. "It just needs an object with a .title attribute. By using duck typing and a tactical late import inside the method, we broke the cycle. This combines restructuring with a minimal late import—the simplest fix."

Moving Shared Code

Another restructuring approach:

# BEFORE (circular):
# models.py → database.py → models.py

# AFTER (hierarchical):
# Create a base module both can import

# library/types.py (new file)
class Book:
    def __init__(self, title):
        self.title = title

# library/database.py
from library.types import Book

def save_to_db(book):
    print(f"Saving {book.title}")

def load_book(book_id):
    # ... load from database
    return Book(title="Loaded Title")

# library/models.py
from library.types import Book
from library.database import save_to_db

# Extend Book with business logic
class EnhancedBook(Book):
    def save(self):
        save_to_db(self)

# No cycle:
# types.py imports nothing
# database.py imports types.py
# models.py imports types.py and database.py
Enter fullscreen mode Exit fullscreen mode

Solution 2: Import Inside Functions (Late Import)

She demonstrated tactical late binding:

Moving imports to function level:

# library/models.py
class Book:
    def __init__(self, title):
        self.title = title

    def save(self):
        # Import at CALL time, not at MODULE LOAD time
        from library.database import save_to_db
        save_to_db(self)

# library/database.py
def save_to_db(book):
    print(f"Saving {book.title}")

def get_all_books():
    # Late import here too
    from library.models import Book
    # ... load data
    return [Book("Book 1"), Book("Book 2")]

# This works! Why?
# When main.py imports models:
#   - models.py completes (Book class is defined)
#   - No import of database yet
# When book.save() is called:
#   - THEN database.py is imported
#   - models.py is already complete
#   - No cycle!
Enter fullscreen mode Exit fullscreen mode

When late imports make sense:

# ✅ Good use cases for late imports:
# 1. Breaking circular dependencies
# 2. Optional dependencies (import only if feature used)
# 3. Performance (expensive module, rarely used)

def export_to_pdf(data):
    # Only import heavy library if this feature is used
    from reportlab.pdfgen import canvas
    # ... generate PDF

# ❌ Avoid late imports for:
# 1. Common imports (makes code harder to read)
# 2. When top-level import works fine
# 3. Performance-critical loops (import has overhead)

# Don't do this:
def process_item(item):
    import json  # BAD - imported every function call!
    return json.loads(item)

# Do this:
import json  # GOOD - import once at top

def process_item(item):
    return json.loads(item)
Enter fullscreen mode Exit fullscreen mode

"Late imports work," Margaret cautioned, "but they hide dependencies. Use them to break cycles, not as standard practice. If you find yourself doing many late imports, your architecture probably needs restructuring."

Solution 3: Import the Module, Not the Name

Timothy learned about importing differently:

Module-level vs name-level imports:

# BEFORE (causes circular import):
# library/models.py
from library.database import save_to_db  # Imports the function

class Book:
    def save(self):
        save_to_db(self)

# library/database.py
from library.models import Book  # Imports the class

# AFTER (breaks the cycle):
# library/models.py
import library.database  # Import the MODULE, not the function

class Book:
    def save(self):
        library.database.save_to_db(self)  # Use module.function

# library/database.py
import library.models  # Import the MODULE, not the class

def save_to_db(book):
    print(f"Saving {book.title}")

def load_book():
    return library.models.Book("Loaded")  # Use module.Class

# Why this works:
# Both modules can import each other as modules
# They access each other's contents via dotted names
# The contents don't have to exist at import time
# They just have to exist when the functions are CALLED
Enter fullscreen mode Exit fullscreen mode

The key difference:

# This fails (imports name at module level):
from library.database import save_to_db
# Python must evaluate save_to_db NOW
# If database.py isn't finished, this fails

# This works (imports module at module level):
import library.database
# Python just notes "we'll need library.database"
# Doesn't need to access save_to_db until you call it
# By call time, both modules are finished

# Later, when called:
library.database.save_to_db(book)
# Now both modules are fully loaded
# save_to_db exists and can be accessed
Enter fullscreen mode Exit fullscreen mode

Solution 4: Type Hints with TYPE_CHECKING

Margaret showed him a modern pattern for type hints:

Avoiding imports needed only for type checking:

# library/models.py
from typing import TYPE_CHECKING

# TYPE_CHECKING is False at runtime, True when type checker runs
if TYPE_CHECKING:
    from library.database import Database  # Only for type checker!

class Book:
    def __init__(self, title: str):
        self.title = title

    def save(self, db: 'Database'):  # String annotation!
        # At runtime, 'Database' is just a string
        # Type checker understands it because of the import above
        db.save_book(self)

# library/database.py
from library.models import Book  # Regular import

class Database:
    def save_book(self, book: Book):
        print(f"Saving {book.title}")

# Why this works:
# At runtime: TYPE_CHECKING is False
#   - The 'if TYPE_CHECKING' block doesn't execute
#   - No circular import!
#   - 'Database' in annotation is just a string
# When type checking (mypy, etc.): TYPE_CHECKING is True
#   - Import executes for the type checker
#   - Type checker understands Database
#   - Full type checking works
Enter fullscreen mode Exit fullscreen mode

Using forward references:

from __future__ import annotations  # Python 3.7+ (PEP 563)

# With this import, ALL annotations are strings automatically
# No need for quotes or TYPE_CHECKING

from library.database import Database

class Book:
    def save(self, db: Database):  # No quotes needed!
        db.save_book(self)

# How it works:
# from __future__ import annotations
# Makes Python treat all type hints as strings
# They're not evaluated at runtime
# No circular import issue
# Type checkers still understand them
Enter fullscreen mode Exit fullscreen mode

Important notes on annotations as strings:

# Originally planned to be default in Python 3.10, then 3.11, then 3.12
# Now POSTPONED INDEFINITELY due to runtime compatibility issues

# Pros:
# ✅ Solves circular import issues for type hints
# ✅ Faster module import (annotations not evaluated)
# ✅ Forward references work automatically

# Cons:
# ⚠️ Breaks some runtime type inspection
# ⚠️ Libraries that use get_type_hints() need updates
# ⚠️ Pydantic, FastAPI, dataclasses may have issues in older versions
# ⚠️ Can't use annotations for runtime behavior

# Best practice as of Python 3.11+:
# - Use TYPE_CHECKING if you need runtime access to types
# - Use from __future__ import annotations if purely for type checkers
# - Test with your dependencies (most modern libs support it)

from typing import TYPE_CHECKING, get_type_hints

if TYPE_CHECKING:
    from library.database import Database

class Book:
    def save(self, db: 'Database'):  # String annotation
        pass

# At runtime, inspect annotations:
print(Book.save.__annotations__)
# {'db': 'Database', 'return': None}
# Annotations are strings - safe from circular imports
Enter fullscreen mode Exit fullscreen mode

Recognizing Circular Import Patterns

She showed him common scenarios:

Pattern 1: Bidirectional relationships:

# A common anti-pattern

# models/user.py
from models.post import Post

class User:
    def get_posts(self) -> list[Post]:
        # User needs to know about Post
        pass

# models/post.py
from models.user import User

class Post:
    def get_author(self) -> User:
        # Post needs to know about User
        pass

# CYCLE! User imports Post, Post imports User

# Solution: Put both in same file, or use TYPE_CHECKING
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Utilities that import clients:

# utils/database.py
from models.user import User  # Utility imports model

def save_user(user: User):
    pass

# models/user.py
from utils.database import save_user  # Model imports utility

class User:
    def save(self):
        save_user(self)

# CYCLE! Common because models use utilities,
# but utilities often need to know model types

# Solution: Make utilities generic (duck typing)
# or use TYPE_CHECKING for type hints
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Parent-child imports:

# parent.py
from child import ChildClass

class ParentClass:
    def create_child(self):
        return ChildClass(parent=self)

# child.py
from parent import ParentClass

class ChildClass:
    def __init__(self, parent: ParentClass):
        self.parent = parent

# CYCLE! Parent imports child, child imports parent

# Solution: Use TYPE_CHECKING or import module not name
Enter fullscreen mode Exit fullscreen mode

When Circular Imports Indicate Design Problems

Margaret explained the deeper issue:

Circular dependencies as a code smell:

# If you have circular imports, ask:

# 1. Are these really separate modules?
#    Maybe they should be one module:
# BEFORE:
#   models/user.py
#   models/post.py (both import each other)
# AFTER:
#   models/user_post.py (both in one file)

# 2. Is there a missing abstraction?
# BEFORE:
#   EmailService imports User
#   User imports EmailService
# AFTER:
#   EmailService imports UserProtocol (interface)
#   User implements UserProtocol
#   No direct dependency!

# 3. Is the dependency backwards?
# BEFORE:
#   Low-level database imports high-level User
#   User imports database
# AFTER:
#   Database is generic (no User knowledge)
#   User imports database
#   One-way dependency!
Enter fullscreen mode Exit fullscreen mode

The Dependency Inversion Principle:

# ANTI-PATTERN: Concrete dependencies
# high_level.py
from low_level import LowLevelThing

class HighLevel:
    def __init__(self):
        self.thing = LowLevelThing()

# low_level.py
from high_level import HighLevel  # CIRCULAR!

class LowLevelThing:
    def process(self, hl: HighLevel):
        pass

# BETTER: Abstract dependencies
# interfaces.py
class ThingInterface:
    def process(self, data): pass

# high_level.py
from interfaces import ThingInterface

class HighLevel:
    def __init__(self, thing: ThingInterface):
        self.thing = thing

# low_level.py
from interfaces import ThingInterface

class LowLevelThing(ThingInterface):
    def process(self, data):
        pass

# No cycle! Both depend on interface, not each other
Enter fullscreen mode Exit fullscreen mode

Debugging Circular Imports

Timothy learned to diagnose the problem:

Finding the cycle:

# Error message:
# ImportError: cannot import name 'Book' from partially initialized module 'library.models'
# (most likely due to a circular import)

# Step 1: Trace the imports manually
# Read each file's top-level imports
# Draw a diagram:
# main.py → models.py → database.py → models.py (CYCLE!)

# Step 2: Use Python's verbose import
python3 -v -c "from library.models import Book"
# Shows every import attempt
# You'll see models.py start, then database.py start,
# then models.py accessed again before finishing

# Step 3: Add debug prints (temporary)
# At top of each module:
print(f"Loading {__name__}")

# library/models.py
print("Loading models.py")  # Prints first
from library.database import save_to_db
print("models.py import done")  # Never prints!

# library/database.py
print("Loading database.py")  # Prints second
from library.models import Book  # Fails here
print("database.py import done")  # Never prints!

# Output shows where cycle occurs:
# Loading models.py
# Loading database.py
# ImportError!
Enter fullscreen mode Exit fullscreen mode

Tools for detecting cycles:

# Use pydeps to visualize dependencies
pip install pydeps
pydeps library --max-bacon=2
# Creates a graph showing import relationships
# Circular dependencies appear as cycles in the graph

# Use importlab (Google's tool)
pip install importlab
importlab library/
# Reports circular dependencies

# Use pylint
pylint library/
# Flags cyclic imports (R0401: cyclic-import)
Enter fullscreen mode Exit fullscreen mode

Real-World Example: Flask App Structure

Margaret showed him a production pattern:

A web application without cycles:

# app/
#   __init__.py
#   models.py
#   views.py
#   database.py

# app/__init__.py (creates app)
from flask import Flask

app = Flask(__name__)

# Import views AFTER app is created
from app import views  # This is okay!

# app/models.py (data models)
# No imports from views or __init__

class User:
    def __init__(self, username):
        self.username = username

# app/database.py (database layer)
# Imports models (data flows up)
from app.models import User

db = {}

def save_user(user):
    db[user.username] = user

def get_user(username):
    return db.get(username)

# app/views.py (request handlers)
# Imports app and database (dependencies flow down)
from app import app
from app.database import save_user, get_user
from app.models import User

@app.route('/user/<username>')
def user_profile(username):
    user = get_user(username)
    return f"User: {user.username}"

# Dependency hierarchy (no cycles):
# __init__.py (creates app)
#   ↓
# views.py (uses app, imports database and models)
#   ↓
# database.py (imports models)
#   ↓
# models.py (no dependencies)
Enter fullscreen mode Exit fullscreen mode

Best Practices Summary

The principles to follow:

# 1. Keep modules loosely coupled
# Each module should have a clear, single responsibility

# 2. Dependencies flow in one direction
# Low-level modules don't import high-level modules
# Data models → Database → Business Logic → Views

# 3. Use interfaces/protocols for flexibility
from typing import Protocol

class Saveable(Protocol):
    def save(self): ...

# Database depends on interface, not concrete classes

# 4. Late imports are tactical, not strategic
# Use to break cycles temporarily
# Then refactor to remove the need

# 5. If you import each other, you're probably one module
# Merge them or extract shared code

# 6. TYPE_CHECKING is your friend
# Use it for type hints that would cause cycles

# 7. Monitor with tools
# Use pylint, pydeps, or importlab
# Catch cycles during development
Enter fullscreen mode Exit fullscreen mode

The Takeaway

Timothy stood in the Circular Import Problem, understanding dependency management.

Circular imports happen when modules import each other: Module A imports B, B imports A—neither can finish.

Python imports modules once: When a cycle occurs, one module gets the other while it's partially initialized.

Module objects exist in sys.modules immediately: But their contents don't exist until execution completes.

"Partially initialized module" error: Means you're accessing something that doesn't exist yet in a module that's still loading.

Same module appearing twice in traceback: Key indicator of circular import—look for duplicate module names.

Late imports break cycles tactically: Import inside functions instead of at module level.

Import module, not names: import module then module.name delays evaluation until call time.

TYPE_CHECKING avoids runtime imports: Type hints in if TYPE_CHECKING block don't execute at runtime.

from future import annotations: Makes all type hints strings automatically, preventing circular issues.

Annotations as strings are postponed: Originally planned as default, now optional due to runtime compatibility issues.

Test with your dependencies: Pydantic, FastAPI, and dataclasses need modern versions for string annotations.

Restructuring is the best solution: Break the cycle by extracting shared code or fixing dependency direction.

Cycles indicate design problems: If modules depend on each other, they might be too coupled or need an interface.

Dependencies should flow one direction: Low-level doesn't import high-level; data flows up, control flows down.

Bidirectional relationships cause cycles: User ↔ Post patterns need TYPE_CHECKING or restructuring.

Utilities importing models is suspicious: Should utilities really need to know about your models?

Debug with python -v: Shows import sequence and where cycles occur.

Tools can detect cycles: pydeps, importlab, and pylint find circular dependencies.

Same file solves many cycles: If two modules always import each other, they might be one module.

Dependency Inversion Principle helps: Both modules depend on shared interface, not each other.

Flask app structure is a pattern: Models → Database → Views, with app creation separate.

Breaking the Cycle

Timothy had discovered how to identify and resolve circular import dependencies.

The Circular Import Problem revealed that import order matters, that modules must complete initialization before their contents can be used, and that circular dependencies prevent either module from finishing.

He learned that module objects appear in sys.modules immediately but remain empty until execution completes, which is why "import module" works but "from module import name" fails during circular imports, and that recognizing the error traceback pattern—with the same module appearing twice—is key to diagnosing the problem.

Moreover, Timothy understood that late imports work by delaying the import until call time when both modules are complete, that importing modules instead of names delays evaluation, and that TYPE_CHECKING allows type hints without runtime imports.

He learned about from __future__ import annotations making all type hints into strings automatically, but also that this feature was postponed as the default behavior due to compatibility issues with libraries that inspect types at runtime, so it must be used explicitly and tested with dependencies.

He understood that restructuring is better than tactical workarounds, that dependency direction should be clear and one-way, and that circular imports often signal design problems like high coupling or missing abstractions.

Most importantly, Timothy understood that avoiding circular imports isn't about memorizing tricks—it's about designing clean module boundaries with clear dependency hierarchies, where each module has a single responsibility and dependencies flow in one direction from low-level to high-level.


Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.

Top comments (4)

Collapse
 
varshithvhegde profile image
Varshith V Hegde

Wow, this was super helpful! I always just googled the error or randomly refactored until it worked, but I never actually understood why circular imports happen. The step-by-step breakdown and solutions made everything click. Thanks for explaining it so clearly!

Collapse
 
aaron_rose_0787cc8b4775a0 profile image
Aaron Rose

cheers varshith ❤

Collapse
 
hashbyt profile image
Hashbyt

This is an incredibly thorough and clear explanation of the Circular Import Problem! The detailed breakdown of Python's execution sequence and the state of sys.modules during the cycle is excellent for really understanding why the error occurs.

Collapse
 
ldrscke profile image
Christian Ledermann

There are also tools to visualize the import dependencies like tach or enforce import rules import-linter

Here is a more complete list of architecture linters