How to avoid circular imports in Python?
Interview Questions practice on Codemia
Over 8,000 real interview questions from top companies, searchable by company and role.
Circular imports in Python occur when two or more modules depend on each other, forming a dependency cycle. This is one of the most common structural problems in Python projects, and it can surface as ImportError, AttributeError, or silently produce None values from partially initialized modules. Learning how to identify and resolve circular imports will help you write cleaner, more maintainable code.
What Are Circular Imports?
A circular import happens when module A imports module B, and module B imports module A (directly or through a chain of other modules). When Python encounters this cycle, it does not re-execute the module that is already being loaded. Instead, it returns whatever has been defined so far, which often means the name you need has not been assigned yet.
Consider two files:
When you run module_a.py, Python begins loading it, hits the import of module_b, starts loading module_b, and then tries to import helper_a from the still-incomplete module_a. At that point helper_a has not been defined yet, so Python raises an ImportError.
Strategy 1: Deferred (Lazy) Imports
The simplest fix is to move the import inside the function that actually needs it. This delays the import until runtime, by which time both modules are fully loaded.
This approach works well when the cross-module call happens infrequently. The import statement is cached by Python after the first execution, so the performance cost is negligible.
Strategy 2: Restructure Into a Third Module
When two modules share definitions that cause a cycle, extract the shared code into a new, independent module that both can import without conflict.
This is the most architecturally sound solution because it eliminates the cycle entirely and makes the dependency graph a clean tree.
Strategy 3: Import at the Module Level With import Instead of from
Using import module_a instead of from module_a import helper_a can sometimes sidestep the problem. With the plain import form, Python only needs the module object to exist (which it does as soon as loading starts). The attribute lookup happens later when you actually call module_a.helper_a().
This works because module_a is already registered in sys.modules by the time module_b finishes loading. The attribute is resolved at call time, not at import time.
Strategy 4: The TYPE_CHECKING Guard
When the circular import exists solely to support type annotations, Python provides an elegant escape hatch. The typing.TYPE_CHECKING constant is True only during static analysis (mypy, Pyright) and False at runtime, so the import never actually executes.
The from __future__ import annotations import (available since Python 3.7) turns every annotation into a string automatically, so you do not need to quote them manually in Python 3.10 and later.
Common Pitfalls
- Hiding the real design problem. Deferred imports fix the symptom but not the cause. If two modules are tightly coupled, refactoring into a third module is usually the better long-term choice.
- Circular imports that fail silently. Sometimes Python does not raise an error but instead sets the imported name to
Nonebecause the module was only partially initialized. This leads to confusingTypeError: 'NoneType' object is not callablemessages at runtime. - Overusing TYPE_CHECKING for runtime logic. The
TYPE_CHECKINGguard only helps with annotations. If you try to instantiate or call the imported class at runtime inside that block, it will fail with aNameError. - Forgetting
from __future__ import annotations. Without this import in Python versions before 3.10, annotations are evaluated eagerly, so a forward-referenced type insideTYPE_CHECKINGstill triggers an error unless you wrap it in quotes. - Creating deep import chains. A cycle does not have to be between just two files. Three or more modules can form a chain (A imports B, B imports C, C imports A) that is much harder to spot. Tools like
pydepsorimport-lintercan visualize your dependency graph.
Summary
- Circular imports happen when two or more modules depend on each other, causing partially initialized modules and import errors.
- Deferred imports (moving the
importinside a function body) are the quickest fix and work well for infrequent cross-module calls. - Extracting shared code into a third module removes the cycle entirely and is the cleanest architectural solution.
- Using
import moduleinstead offrom module import namedefers attribute resolution to call time, which can break the cycle. - The
TYPE_CHECKINGguard combined withfrom __future__ import annotationseliminates cycles that exist only for type hints without any runtime cost.
Related reading
- How to avoid .pyc files?
- How to avoid reinstalling packages when building Docker image for Python projects?
- How to avoid reinstalling packages when building Docker image for Python projects?
- How to avoid RuntimeError dictionary changed size during iteration error?
- How to avoid the out of range error using shuffle_batch function?
- How to batch process incoming tasks into 10 task in celery?
- How to bootstrap installation of Python modules on Amazon EMR?
- How to build a basic iterator?
.png&w=3840&q=75)
Tackling System Design Interview Problems
A short course that equips you with the skills to approach system design interviews methodically.
Start the free courseTrack what you have practised
A free account saves your progress, solutions and study plan across every problem on Codemia.
Interview Questions practice on Codemia
Over 8,000 real interview questions from top companies, searchable by company and role.