Circular Import Mechanics
A circular import is two (or more) modules that import each other:
aimportsbwhilebimportsa. Whether this works or raises depends entirely on when each module touches the name it wants from the other — and the deciding fact is that CPython inserts a module intosys.modulesbefore running its body. A cyclic re-entry therefore finds a real but partially initialized module object: the names defined after the import statement that triggered the cycle do not exist yet.import a(which binds the module object) almost always survives a cycle because it defers attribute access;from a import name(which immediately binds an attribute) raisesImportError: cannot import name 'name' from partially initialized module 'a'when that attribute has not been defined yet (per the import reference andPython/ceval.c). This note traces the exact mechanism as of CPython 3.14.5 and shows how to read the resulting traceback.
Mental Model: The Module Is Real Before It Is Finished
The single fact that explains every circular-import outcome is this, stated plainly in the import reference: “The module will exist in sys.modules before the loader executes the module code. This is crucial because the module code may (directly or indirectly) import itself; adding it to sys.modules beforehand prevents unbounded recursion in the worst case and multiple loading in the best.” (See sys.modules and the Module Cache for the cache itself.)
So when a’s body runs import b, and b’s body in turn runs import a, that second import a does not re-execute a — it finds a already sitting in sys.modules and returns it immediately. The catch: a is only partially run. Everything textually above the import b line in a has executed; everything below it has not. The names defined below do not exist on the module yet.
sequenceDiagram participant Run as interpreter participant SM as sys.modules participant A as module a (body) participant B as module b (body) Run->>SM: import a -> insert empty a, mark _initializing=True Run->>A: exec a body A->>A: (top of a runs) A->>SM: import b -> insert empty b, mark _initializing A->>B: exec b body B->>SM: import a -> ALREADY PRESENT, returns partial a Note over B: a.x not defined yet!<br/>import a: OK (binds module)<br/>from a import x: ImportError<br/>a.x access: AttributeError B-->>A: b body finishes A->>A: x = "a-value" (rest of a runs) A-->>Run: a body finishes
Figure: the timeline of a ↔ b. The insight: at the moment b re-enters a, the module object a exists in sys.modules but its body has only run up to the import b line — so a.x, defined below that line, is missing. Whether the cycle survives depends on whether b merely binds the module (import a, fine) or demands a not-yet-defined attribute (from a import x / a.x, fails).
The Mechanism, Step by Step
The insertion-before-execution happens in _load_unlocked in Lib/importlib/_bootstrap.py:
def _load_unlocked(spec):
module = module_from_spec(spec)
# This must be done before putting the module in sys.modules
# (otherwise an optimization shortcut in import.c becomes wrong).
spec._initializing = True
try:
sys.modules[spec.name] = module # (1) inserted FIRST
try:
spec.loader.exec_module(module) # (2) body runs SECOND
except:
try:
del sys.modules[spec.name] # (3) on failure, remove it
except KeyError:
pass
raise
module = sys.modules.pop(spec.name) # (4) re-fetch + move to end
sys.modules[spec.name] = module
finally:
spec._initializing = False # (5) clear the flag
return moduleReading the numbered points: (1) the freshly created, empty module object is placed into sys.modules under its name. (2) only then does exec_module run the module’s body, which is where any import statements inside the module fire — and where a cycle re-enters. (3) if the body raises, the module is removed from sys.modules so a later retry starts clean (this is why a failed import does not leave a broken half-module cached). (4) on success the module is popped and re-inserted to move it to the end of the ordered dict. (5) the _initializing flag — set to True at the top and cleared in the finally — is the marker the rest of the machinery uses to recognize “this module is mid-execution,” i.e. partial.
That _initializing flag is consulted in _find_and_load, the function __import__ ultimately calls:
def _find_and_load(name, import_):
module = sys.modules.get(name, _NEEDS_LOADING)
if (module is _NEEDS_LOADING or
getattr(getattr(module, "__spec__", None), "_initializing", False)):
with _ModuleLockManager(name):
module = sys.modules.get(name, _NEEDS_LOADING)
if module is _NEEDS_LOADING:
return _find_and_load_unlocked(name, import_)
_lock_unlock_module(name)
# ...
return moduleWhen b runs import a, this checks sys.modules. a is present (the _NEEDS_LOADING sentinel is not returned), so _find_and_load does not re-run a’s body. It simply returns the existing — partial — module object. That return value is exactly what gets bound to the name a in b’s namespace. This is why import a survives a cycle: it binds the module object, which already exists; it never asks whether any particular attribute of a is defined yet.
Why import a Survives but from a import x Raises
The asymmetry is not “import good, from-import bad” — it is about when the attribute is accessed. import a binds the module object and defers any attribute access to later. from a import x demands the attribute x right now, at import time, while a is still partial.
from a import x compiles to an IMPORT_NAME (which runs __import__ and yields the module) followed by IMPORT_FROM x. The IMPORT_FROM opcode is handled by _PyEval_ImportFrom in Python/ceval.c:
PyObject *
_PyEval_ImportFrom(PyThreadState *tstate, PyObject *v, PyObject *name)
{
PyObject *x;
if (PyObject_GetOptionalAttr(v, name, &x) != 0) {
return x; // attribute exists -> success
}
/* Issue #17636: in case this failed because of a circular relative
import, try to fallback on reading the module directly from
sys.modules. */
/* ... build fullmodname = "a.name", try PyImport_GetModule(...) ... */
/* if still nothing: */
error:
/* ... construct the ImportError message ... */
}Step by step: IMPORT_FROM first does getattr(a, "x"). If x is an attribute of a, it returns it — done. If not (the partial-module case, where x is defined below the line that triggered the cycle), it falls back to checking whether a.x is itself an importable submodule in sys.modules (this handles the legitimate case from package import submodule during a relative-import cycle, bpo-17636). If that also fails, it constructs an ImportError. The message is chosen by inspecting the spec’s _initializing flag:
int rc = _PyModuleSpec_IsInitializing(spec);
if (rc > 0) {
/* ... */
errmsg = PyUnicode_FromFormat(
"cannot import name %R from partially initialized module %R "
"(most likely due to a circular import) (%S)",
name, mod_name_or_unknown, origin);
}So the famous “cannot import name ‘x’ from partially initialized module ‘a’ (most likely due to a circular import)” message is emitted precisely when the attribute is missing and the source module’s spec is still _initializing. If the module had already finished (rc == 0) but the attribute were genuinely absent, you would instead get the plainer "cannot import name 'x' from 'a'" — a real typo or a name that never existed.
By contrast, import a (without from) never reaches IMPORT_FROM; it just binds the module object. The cost is deferred: if b then uses a.x at its own top level (still during the cycle), the access fails — but with AttributeError, not ImportError, because now it is an ordinary attribute lookup on a real module object. Both are the same underlying problem (the attribute does not exist yet); they differ only in which opcode hits it first.
Three reproducible outcomes (verified on 3.14.5)
With a.py = import b then x = "a-value", and varying b.py:
b.py | Result | Why |
|---|---|---|
from a import x | ImportError “cannot import name ‘x’ from partially initialized module ‘a’” | IMPORT_FROM demands a.x while a is partial |
import a print(a.x) | AttributeError “module ‘a’ has no attribute ‘x’” | module binds fine; a.x access fails because x not defined yet |
import a def get(): return a.x | works | a.x is only read when get() is called, long after a finished |
The third row is the whole game: deferring the attribute access until after the cycle has fully unwound makes the partial-module window irrelevant.
Resolved (2026-06-01)
Verified against
Python/ceval.cat thev3.14.5tag (theIMPORT_FROMerror path, lines ~3214–3275) and reproduced on live 3.14.5. The branch key isint is_possibly_shadowing = _PyModule_IsPossiblyShadowing(origin)(line 3214), and there are actually three message forms, not two:
- Stdlib shadowing (
is_possibly_shadowing_stdlib): “cannot import name … from … (consider renaming … since it has the same name as the standard library module named … and prevents importing that standard library module)”.- Non-stdlib shadowing while the module is initializing: “cannot import name … from … (consider renaming … if it has the same name as a library you intended to import)” — this is the form quoted above; live reproduction with a top-level
a.py↔b.pycycle gave exactlycannot import name 'y' from 'b' (consider renaming '/…/b.py' if it has the same name as a library you intended to import).- Plain circular import (no shadowing): “cannot import name … from partially initialized module … (most likely due to a circular import) (…)” — reproduced with a
mypkg.a↔mypkg.bpackage cycle.The takeaway for a reader debugging a cycle is unchanged: any of these phrasings means the attribute is not defined yet. Source: ceval.c@v3.14.5.
The Fix: Move or Defer the Import
Two fixes follow directly from the mechanism.
Function-local imports. Moving from a import x from the top of b into the function that needs it defers the import until call time, by which point a has finished initializing and a.x exists:
# b.py — top-level import would fail mid-cycle; function-local does not
def do_work():
from a import x # runs only when do_work() is called, after a finished
return xThis is the canonical fix and is essentially free (a local import is a cheap sys.modules lookup after the first time, see sys.modules and the Module Cache).
Bind the module, access attributes lazily. Replacing from a import x with import a and using a.x at the point of use (inside functions, not at module top level) works for the same reason — the module binding succeeds during the cycle and the attribute is only read later.
A third, structural fix is to break the cycle: extract the shared names a and b both depend on into a third module c that neither imports back, so the dependency graph becomes acyclic. This is the right fix when the cycle reflects a genuine design problem rather than an ordering accident.
Thread Safety: Per-Module Locks and Deadlock Avoidance
Circular imports are dangerous in threaded code for a second reason beyond partial modules: two threads importing each other’s modules could deadlock. CPython guards each module with its own _ModuleLock (a re-entrant lock) rather than one global import lock, and it actively detects import deadlocks. From Lib/importlib/_bootstrap.py:
class _ModuleLock:
"""A recursive lock implementation which is able to detect deadlocks
(e.g. thread 1 trying to take locks A then B, and thread 2 trying to
take locks B then A)."""
def acquire(self):
tid = _thread.get_ident()
with _BlockingOnManager(tid, self):
while True:
with self.lock:
if self.count == [] or self.owner == tid:
# unowned, or owned by THIS thread -> grant immediately
self.owner = tid
self.count.append(True)
return True
if self.has_deadlock():
raise _DeadlockError(f'deadlock detected by {self!r}')
# else queue up as a waiter and block on self.wakeupTwo design choices matter for circular imports. First, the lock is re-entrant (RLock-like): if the same thread that already holds module a’s lock re-enters to import a again (exactly what a single-threaded circular import does), the self.owner == tid branch grants the lock immediately — “This supports circular imports (thread T imports module A which imports module B which imports module A),” per the source comment. Without re-entrancy, a single-threaded cycle would self-deadlock. The count is tracked as a list of True because list.append/list.pop are atomic in CPython.
Second, when a different thread holds the lock, has_deadlock() walks the global _blocking_on thread→lock graph (via _has_deadlocked) to see whether granting would close a cycle (thread 1 holds A wants B; thread 2 holds B wants A). If so it raises _DeadlockError rather than hanging forever. The source comment notes this is “more than just a hypothetical” and links to a real Django REST Framework issue. There is also a _DummyModuleLock for interpreters built without threads, and the whole scheme is re-entrancy-aware to survive the import system being re-entered by signal handlers and the garbage collector (a __del__ that imports). This matters more under Free-Threaded CPython, where genuinely concurrent imports are common; see also The Global Interpreter Lock.
Diagnosing a Circular Import from the Traceback
The traceback is a precise map of the cycle. Reading the verified 3.14.5 example for the package cycle mypkg.a ↔ mypkg.b:
File ".../mypkg/a.py", line 1, in <module>
import mypkg.b
File ".../mypkg/b.py", line 1, in <module>
from mypkg.a import x
ImportError: cannot import name 'x' from partially initialized module 'mypkg.a'
(most likely due to a circular import) (.../mypkg/a.py)
Three signals to read: (1) the phrase “partially initialized module” plus “(most likely due to a circular import)” is CPython telling you outright this is a cycle, not a typo. (2) The traceback frames show the entry order of the cycle — a was imported first (so it is the one left partial), then it imported b, then b tried to reach back into a. The module named in the error (mypkg.a) is the partial one; the line in b that triggered it is the second frame. (3) The parenthesized path (.../mypkg/a.py) is the origin of the partial module, useful when two modules share a short name.
Compare this against a plain "cannot import name 'x' from 'a'" without “partially initialized” — that means a finished loading and x genuinely is not there (a typo, a removed symbol, a wrong module). And an AttributeError: module 'a' has no attribute 'x' deep in some function, when the rest of the program runs fine, often means a circular import where you used import a; a.x and the access happened to fire during the partial window. The fix in every case follows from the section above: defer the access, or break the cycle.
A useful runtime probe: inside a suspected partial module, import sys; print(sys.modules['a'].__spec__._initializing) is True while the cycle is live. And vars(sys.modules['a']).keys() shows exactly which names have been defined so far — everything below the triggering import line will be absent.
Common Misunderstandings
- “Circular imports are always an error.” False — they frequently work. The cycle only fails if a not-yet-defined name is demanded during the partial window. Large codebases routinely rely on cycles working, typically via function-local imports or
TYPE_CHECKING-guarded imports that never execute at runtime. - “
from a import xis the problem.” The problem is eager attribute access at import time.from a import xis the most common way to do that, butimport afollowed by top-levela.xfails identically (asAttributeError). - “
sys.modulescaches the finished module.” It caches the module object from the moment of creation — including while it is still partial. That is the entire reason cycles can return a half-built module rather than recursing forever. - “A failed import leaves a half-module cached.” No —
_load_unlockeddeletes the module fromsys.modulesif the body raises, so a retry starts fresh. (Afrom a import xthat fails inside a still-runningadoes not corrupta’s eventual completion, though it does abortb.)
See Also
- sys.modules and the Module Cache — the cache that holds the partial module and makes cycles terminate
- The Python Import System — the end-to-end import flow
__import__drives - Finders and Loaders · Module Objects and Namespaces
- Free-Threaded CPython · The Global Interpreter Lock — why per-module locks and deadlock detection matter
- Parent: Python Internals MOC §11 The Import System