Avoiding Import Traps
Python's import system is powerful but has a few traps that catch every developer at some point. This lesson covers the three most common: circular imports (two files that import each other), star imports (importing everything with *), and namespace collisions (two modules defining the same name). Knowing these traps in advance saves hours of debugging.
Circular imports, from x import *, and shadowing. This lesson shows the failure mode for each, explains why it happens, and teaches three fix strategies for circular dependencies: extract shared code, defer the import, or use TYPE_CHECKING.
James splits SmartNotes into modules. Models in one file, search in another. Everything works. Then he adds a method to the Note class that uses search_notes:
# models.py
from smartnotes.search import search_notes # 🔴 Problem!
@dataclass
class Note:
...
def find_related(self, all_notes: list["Note"]) -> list["Note"]:
return search_notes(all_notes, self.title.split()[0])
He runs the code:
ImportError: cannot import name 'Note' from partially initialized module
'smartnotes.models' (most likely due to a circular import)
"What happened?" James asks.
"Circular import," Emma says. "models.py imports search.py, and search.py imports models.py. Python gets stuck in a loop."
Trap #1: Circular Imports
A circular import happens when two modules depend on each other:
models.py ──imports──→ search.py
↑ │
└────────imports─────────┘
Here is a minimal example:
# a.py
from b import greet
def get_name() -> str:
return "Alice"
# b.py
from a import get_name
def greet() -> str:
return f"Hello, {get_name()}!"
# main.py
from a import get_name
print(get_name())
Run main.py:
ImportError: cannot import name 'greet' from partially initialized module 'b'
What happened:
main.pyimportsa.pya.pystarts loading and hitsfrom b import greet- Python starts loading
b.py b.pyhitsfrom a import get_name- But
a.pyis not finished loading yet (it is "partially initialized") - Python cannot find
get_namebecausea.pyhas not defined it yet ImportError
Three Ways to Fix Circular Imports
Fix 1: Extract Shared Code (Best)
Move the shared dependency to a third module that both files import:
Before: After:
a.py ←→ b.py a.py → shared.py ← b.py
(circular) (no cycle)
For SmartNotes: move Note out of models.py into a separate types.py or keep it in models.py and remove the search import from models.py.
# models.py -- no import of search
@dataclass
class Note:
...
# Remove find_related from here
# search.py -- imports models (one-way dependency)
from smartnotes.models import Note
def search_notes(notes: list[Note], keyword: str) -> list[Note]:
...
def find_related(note: Note, all_notes: list[Note]) -> list[Note]:
"""Find notes related to the given note."""
return search_notes(all_notes, note.title.split()[0])
The find_related function moves from models.py to search.py. No circular dependency.
Fix 2: Defer the Import
Move the import inside the function that uses it:
# models.py
@dataclass
class Note:
...
def find_related(self, all_notes: list["Note"]) -> list["Note"]:
from smartnotes.search import search_notes # Import here, not at top
return search_notes(all_notes, self.title.split()[0])
The import happens when find_related is called, not when the module loads. By call time, both modules are fully loaded. This works but is considered a workaround, not a solution. Use Fix 1 when possible.
Fix 3: TYPE_CHECKING Import (For Type Hints Only)
This fix is advanced. You do not need it now, but you will see it in professional codebases. If the circular import only exists because of type hints (not runtime code), you can guard the import so it only runs during type checking:
# search.py
from __future__ import annotations # Treats all type hints as strings, not live code
from typing import TYPE_CHECKING # A special variable: True during type checking, False at runtime
if TYPE_CHECKING:
from smartnotes.models import Note
def search_notes(notes: list[Note], keyword: str) -> list[Note]:
...
The from __future__ import annotations line tells Python to treat all type hints as plain strings instead of evaluating them. This means Python does not need to actually import Note at runtime. The TYPE_CHECKING variable is True only when pyright analyzes the code, so the import inside the if block runs during type checking but is skipped when the program actually runs. This breaks the circular dependency at runtime while keeping type safety.
| Fix | When to use | Tradeoff |
|---|---|---|
| Extract shared code | Always the first choice | Requires restructuring |
| Defer import | Quick fix for one function | Import hidden inside function |
| TYPE_CHECKING | Type hints cause the cycle | Only works for annotation-only imports |
Trap #2: Star Imports
A star import imports everything from a module:
from smartnotes.models import *
from smartnotes.search import *
This is dangerous because you do not control what names enter your file. A namespace is the set of names (variables, functions, classes) available in your current file. When you import something, it enters your namespace. If both modules define a function called validate, the second import silently overwrites the first:
# models.py
def validate(note):
"""Validate a note has a title."""
return bool(note.title)
# search.py
def validate(query):
"""Validate a search query is not empty."""
return bool(query)
# main.py
from models import *
from search import *
# Which validate is this?
result = validate(my_note) # 🔴 This is search.validate, not models.validate!
The second * import silently replaced models.validate with search.validate. No error, no warning. Your code calls the wrong function.
Rule: Never use from x import * in production code. Always import specific names:
from smartnotes.models import Note
from smartnotes.search import search_notes
Trap #3: Namespace Collisions (Shadowing)
Shadowing means accidentally replacing an existing name with a new value. Even without star imports, you can shadow a name:
# main.py
from smartnotes.models import Note
# Later in the same file...
Note = "This is a string now" # 🔴 Overwrites the class!
note = Note("Tips", "Learn", 3) # TypeError: 'str' is not callable
Assigning to Note replaces the imported class with a string. Python does not warn you.
Common shadowing mistakes:
| Shadowed name | What happened | Prevention |
|---|---|---|
list = [1, 2, 3] | Overwrites the built-in list type | Use items or values instead |
type = "premium" | Overwrites the built-in type function | Use account_type or kind |
input = "hello" | Overwrites the built-in input function | Use user_input or raw |
Note = "string" | Overwrites your imported Note class | Avoid reusing imported names |
Import Best Practices
Follow these conventions to avoid all three traps:
1. Imports at the top of the file. Every import should be in the first section of the file, after the module docstring. This makes dependencies visible at a glance.
"""SmartNotes search operations."""
# Standard library
import json
from pathlib import Path
# Third party
import pytest
# Local
from smartnotes.models import Note
2. Group imports in order. Standard library first, third-party libraries second, local imports third. Separate each group with a blank line.
3. Use absolute imports. from smartnotes.models import Note, not from .models import Note.
4. Import specific names. from smartnotes.models import Note, not from smartnotes.models import *.
5. Never shadow built-in names. Do not name variables list, type, input, dict, str, int, id, open, or print.
PRIMM-AI+ Practice: Predict the Error
Predict [AI-FREE]
Press Shift+Tab to enter Plan Mode.
# fileA.py
from fileB import say_hello
name = "Alice"
# fileB.py
from fileA import name
def say_hello():
print(f"Hello, {name}")
# main.py
from fileA import name
print(name)
What happens when you run main.py? Does it print "Alice" or does it raise an error? If it raises an error, what kind?
Check your prediction
ImportError: cannot import name 'say_hello' from partially initialized module 'fileB'
Circular import. main.py imports fileA, which imports fileB, which tries to import from fileA (which is not finished loading). The fix: remove the circular dependency by having say_hello accept name as a parameter instead of importing it.
Run
Press Shift+Tab to exit Plan Mode.
Create the three files and run main.py. Verify the error matches your prediction. Then fix it.
Investigate
Write one sentence explaining at which step in the Predict exercise Python got stuck. What was "partially initialized" and why?
If you want to go deeper, run /investigate @fileA.py in Claude Code and ask: "How do I detect circular imports before they cause runtime errors? Is there a tool that draws the dependency graph?"
Modify
Fix the circular import from the Predict exercise using the "extract shared code" approach: create a shared.py that both fileA.py and fileB.py import from.
Make [Mastery Gate]
Review your smartnotes/ package. Draw the import dependency graph on paper:
- Which modules import from which?
- Are there any cycles?
- Are there any star imports?
Verify with:
uv run pyright smartnotes/
uv run python -c "import smartnotes; print('No circular imports')"
Try With AI
If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.
Prompt 1: Find My Import Problems
Here is my SmartNotes package structure and the imports
in each file:
[paste __init__.py, models.py, search.py, storage.py imports]
Are there any circular dependencies? Any star imports?
Any shadowed names? Draw the dependency graph for me.
What you're learning: The AI can analyze import structures and identify problems you might miss. You are practicing code review focused on module dependencies.
Prompt 2: Real-World Circular Import
Show me a real-world example of a circular import in
a Python project. How did the developers fix it? Was
it Fix 1 (extract), Fix 2 (defer), or Fix 3 (TYPE_CHECKING)?
What you're learning: Circular imports are not beginner mistakes. They happen in large projects. Seeing how professionals handle them validates the fix strategies you just learned.
Prompt 3: Dependency Analysis
In Claude Code, type:
/tdg
Use the TDG workflow to write and test a function check_imports(package_dir: Path) -> list[str] that scans all .py files in a directory, extracts their import statements, and returns a list of any circular dependencies found. Write tests first (including a case with a known cycle), then generate.
What you're learning: Automating import analysis is a real engineering tool. You are building a utility that you could use on any Python project.
James draws the SmartNotes dependency graph on the whiteboard:
__init__.py → models.py
→ search.py → models.py
→ storage.py → models.py
"No cycles," he says. "Everything flows in one direction. Models at the bottom, everything else depends on it."
"That is the key insight," Emma says. "Dependencies should form a tree, not a web. If you draw arrows and they form a loop, you have a circular import. Models should never import from search. Search should never import from storage. Keep the arrows pointing one way."
"Like the warehouse supply chain," James says. "Raw materials flow to manufacturing, manufacturing to packaging, packaging to shipping. Shipping never feeds back into raw materials."
Emma nods. "You have modules, packages, imports, and entry points. One more step and you can ship this as a proper project: the capstone ties it all together into a package that someone else can install and use."