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'
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
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!
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
"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
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
"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
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!
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)
"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
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
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
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
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
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
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
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
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!
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
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!
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)
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)
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
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)
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!
cheers varshith ❤
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.
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