Detect Circular Imports Across Python Projects Automatically
This script walks through all .py files in a directory, builds an import graph, and uses depth-first search to find cycles—printing each circular dependency chain.
Python code
63 linesimport ast
import sys
from pathlib import Path
from collections import defaultdict, deque
def find_imports(filepath):
"""Return set of module names imported by a Python file."""
imports = set()
try:
with open(filepath) as f:
tree = ast.parse(f.read())
except (SyntaxError, UnicodeDecodeError):
return imports
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
imports.add(alias.name.split('.')[0])
elif isinstance(node, ast.ImportFrom):
if node.module:
imports.add(node.module.split('.')[0])
return imports
def detect_circular_imports(root_dir):
"""Detect circular imports in all .py files under root_dir."""
root = Path(root_dir)
files = list(root.rglob("*.py"))
module_graph = defaultdict(set)
for f in files:
module_name = f.stem
for imp in find_imports(f):
module_graph[module_name].add(imp)
visited = set()
stack = set()
cycles = []
def dfs(node, path):
visited.add(node)
stack.add(node)
for neighbor in module_graph.get(node, set()):
if neighbor not in visited:
if dfs(neighbor, path + [neighbor]):
return True
elif neighbor in stack:
cycle = path[path.index(neighbor):]
cycles.append(cycle + [neighbor])
return True
stack.discard(node)
return False
for module in list(module_graph.keys()):
if module not in visited:
dfs(module, [module])
return cycles
if __name__ == "__main__":
if len(sys.argv) > 1:
root_path = sys.argv[1]
else:
root_path = "." # default current directory
cycles = detect_circular_imports(root_path)
if cycles:
for cycle in cycles:
print(" -> ".join(cycle))
else:
print("No circular imports detected.")
Output
module_a -> module_b -> module_a
module_x -> module_y -> module_z -> module_x
How it works
The script parses each Python file with ast to statically extract imported top-level module names. It builds a directed graph where edges go from a module to the modules it imports. A DFS with a recursion stack tracks nodes currently being explored; if a neighbor is already on the stack, a cycle has been found. The path list records the traversal order so the exact cycle can be reconstructed. Only first-level module names are considered (e.g., package.module becomes package) because circular imports typically involve top-level packages or files. This approach is fast and requires no runtime imports.
Common mistakes
- Using `sys.modules` or actual imports instead of static AST analysis, which can run code and miss files not yet imported.
- Forgetting to handle syntax errors or non-UTF-8 files, causing the script to crash early.
- Assuming relative imports or multi-dot names correctly resolve without stripping submodules.
- Not handling that modules with the same name in different subdirectories may be conflated if only `stem` is used as the key.
Variations
- Add `--exclude` argument to skip directories like `venv/` or `__pycache__` for faster scanning.
- Use `networkx` to run `simple_cycles()` for larger or deeper graphs in one call.
Real-world use cases
- Running in CI/CD to block merges that introduce circular imports into a shared Python monorepo.
- Analyzing a legacy project before refactoring to eliminate import cycles that cause brittle startup order.
- Validating that new microservice libraries don't create circular dependencies across package boundaries.
Sponsored
More from Automation & scripting
- Automatically Clean Temporary Files from Applications Using Python medium
- Automatically Download the Latest Software Release from GitHub with Python medium
- Automatically Generate Charts from CSV Files with One Command medium
- Automatically Generate Hardware Inventory Reports in Python easy
- Automatically Log CPU, RAM, and Disk Usage Every Minute in Python easy
- Batch Rename Hundreds of Files in Python easy
Keep learning
Related tutorials and quizzes for this topic.