Mastery
Mastery/Python/H. Imports & packaging
T1 · high-leverage

sys.modules as the import cache, and why circular imports fail the way they do

import doesn't necessarily re-run a module's code -- the first successful import stores the module object in sys.modules keyed by name, and every subsequent import of the same name just returns that cached object. This is why module-level state persists across "re-imports," and it's the exact mechanism behind circular-import failures: a module gets a partially-initialized entry in sys.modules the moment it starts running, before it finishes -- so a circular import doesn't get a missing module, it gets an incomplete one.

python
import sys, tempfile, pathlib

tmpdir = tempfile.mkdtemp()
(pathlib.Path(tmpdir) / "counter_mod.py").write_text(
    "count = 0\n"
    "def increment():\n"
    "    global count\n"
    "    count += 1\n"
    "    return count\n"
)
sys.path.insert(0, tmpdir)

import counter_mod
print("first import, increment():", counter_mod.increment())
print("increment() again:", counter_mod.increment())

import counter_mod as cm2   # NOT re-executed -- returned straight from the cache
print("re-imported (as cm2) is the SAME object:", cm2 is counter_mod)
print("cm2.count reflects the prior increments:", cm2.count)
print("'counter_mod' in sys.modules:", "counter_mod" in sys.modules)

Here's a real circular import, reproduced with two actual files written to disk at runtime (mod_a.py imports mod_b, mod_b imports mod_a back and tries to use something from it):

python
import subprocess

circdir = tempfile.mkdtemp()
(pathlib.Path(circdir) / "mod_a.py").write_text(
    "print('  mod_a: starting')\n"
    "import mod_b\n"
    "print('  mod_a: finished')\n"
    "X = 1\n"
)
(pathlib.Path(circdir) / "mod_b.py").write_text(
    "print('  mod_b: starting')\n"
    "import mod_a\n"
    "print('  mod_b: using mod_a.X:', mod_a.X)\n"
)

result = subprocess.run(
    ["python3", "-c", "import sys; sys.path.insert(0, '.'); import mod_a"],
    cwd=circdir, capture_output=True, text=True
)
print(result.stdout, end="")
print("error:", result.stderr.strip().splitlines()[-1])

Interview angle

"Why does a circular import sometimes work and sometimes throw an AttributeError/ImportError?" is a strong question because the correct answer requires understanding sys.modules as a cache of partially-built module objects, not just "circular imports are bad." Candidates who can explain why the error message says "partially initialized" — rather than just knowing to avoid the pattern — show they've actually read the import system, not just hit the error and restructured code until it went away.

In the industry

Circular imports are one of the most common structural bugs in growing Python codebases, and the standard fixes are well-established: move the import inside the function that needs it (deferring it past module load time), restructure to break the cycle, or extract the shared piece into a third module both sides import from. sys.modules itself is also directly useful for debugging — checking whether a module was already imported (and from where) is a real technique for tracking down "why is my monkey-patch not taking effect" bugs, which usually come down to patching a different cached module instance than the one actually in use.