Modules and imports deep: sys.modules, finders and loaders, and the circular-import trap
After the first exec, import is a sys.modules lookup — modules are process-global singletons. Finders on sys.meta_path locate code, loaders run it once; the script dir shadows stdlib names; circular from-imports hit half-initialized modules; python -m runs with package context.
The payments service took 14 seconds to start. Not to serve traffic — to reach the first line of main(). Kubernetes was killing pods mid-rollout because the readiness probe gave them ten. python -X importtime printed the receipt: one innocuous chain — app.handlers imported app.analytics, which imported pandas, which someone had paired with torch for a single function signature. The annotation took a pandas.DataFrame and returned a torch.Tensor, the function was called once a day by a batch job, and yet every web worker paid 12 seconds of import for it, because module-level imports run at import time, unconditionally, for every process that touches the chain. The fix was four lines: move both imports under if TYPE_CHECKING: and import lazily inside the one function that runs the model. Startup fell to 1.3 seconds. Nobody had written slow code — they had written code in the wrong place, and the import system executed it exactly as designed.
import is a dict lookup — after the first time
By the end of this section you’ll know exactly why moving two lines of code turned 14 seconds into 1.3 — and how to diagnose the same pattern in your own codebase.
import json does something expensive at most once per process. The first time, Python locates the module, executes its entire top-level code in a fresh namespace, and stores the resulting module object in sys.modules — a plain dict keyed by module name. Every later import json, anywhere in the process, is a dict hit: nanoseconds, no I/O, no re-execution. Two consequences run the daily show. First, module top-level code is run-once process init — that is precisely why the Hook’s import pandas cost 12 seconds in every worker: each fresh process pays the first-import price for the whole transitive chain. Second, modules are process-global singletons: settings.DEBUG = True mutates the one shared object every importer sees, which is the entire mechanism behind monkeypatching — and behind the test that mutated a module attribute and quietly changed behavior in an unrelated suite. Deleting an entry from sys.modules forces re-execution on the next import; that is what tools like pytest plugin reloaders exploit, and what you should almost never do by hand.
The machinery: finders, loaders, and the sys.path anatomy
On a cache miss, Python walks sys.meta_path — a list of finders. Stock CPython installs three: one for built-ins, one for frozen modules, and PathFinder, which walks sys.path directory by directory. The finder that claims the name returns a ModuleSpec carrying a loader; the import system then creates an empty module object, registers it in sys.modules before executing anything, and asks the loader to run the module code into that namespace. Hold on to that ordering — registered first, executed second — it is the single fact that decides which circular imports survive.
sys.path itself has a loaded gun at index zero: when you run python script.py, entry zero is the script directory (with -m or the REPL it is the current directory). Local files outrank the standard library. The classic: a helper named random.py in the working directory, and three layers down some library does import random and gets your file. The error arrives far from the cause — AttributeError: module 'random' has no attribute 'choice' deep inside a third-party stack trace; if your random.py itself imports random, you get the circular-import variant instead. Python 3.13 finally prints a hint suggesting you rename the shadowing file; on anything older, the diagnostic move is import random; print(random.__file__). Discipline that prevents the class: absolute imports by default, relative imports (from . import db) only for intra-package references — and never name a module after anything in the standard library.
A teammate adds tools/random.py for test fixtures and runs scripts from tools/. The suite now dies with AttributeError: module 'random' has no attribute 'choice' — inside requests, which they never touched. What happened?
Packages: init.py executes, and the namespace-package caveat
import pkg.sub is two imports: Python imports pkg first — which means pkg/__init__.py executes — then pkg.sub. That makes a heavy __init__.py a tax on the entire package: the popular convenience pattern of re-exporting everything (from .client import Client, ten more lines) forces every consumer who wanted one small submodule to pay for all of them. The 12-second Hook chain was exactly this shape — an __init__.py aggregating submodules that aggregated pandas. Keep package inits to re-exports of cheap names, and push heavy wiring into the modules that need it.
Drop the __init__.py and you get a namespace package: importable, no init code, and — the actual feature — multiple directories on sys.path can contribute portions that merge into one logical package (company.auth from one distribution, company.billing from another). The honest costs: no package-level initialization hook at all, a slightly slower scan (every path entry must be consulted), and the accident mode — forgetting __init__.py silently produces a namespace package instead of an error, which masks layout mistakes until packaging time. If you are not deliberately splitting one namespace across distributions, keep explicit __init__.py files.
Circular imports: why from-import is the one that dies
When you encounter a ImportError: cannot import name 'X' from partially initialized module, the instinct is to shuffle import order — but the real question is which kind of import your code uses and when it demands the name. Understanding that distinction unlocks the right fix instead of the lucky one.
Module a imports b; halfway through executing b, it imports a back. Because a was registered in sys.modules before its body started executing, the cycle does not recurse — b simply receives the half-initialized module object: whatever a had bound above its import b line exists; everything below does not yet.
# a.py # b.py
config = {"region": "eu"} import a # gets half-built a: fine
import b # from a import helper ← ImportError!
def helper(): ... def use():
return a.helper() # call time: a is completeimport a in b survives because it only binds the module object and defers attribute access to call time — by then a has finished executing. from a import helper demands the attribute right now, mid-execution, and raises ImportError: cannot import name 'helper' from partially initialized module 'a' (most likely due to a circular import) — CPython literally names the disease. The fixes, ranked: restructure — extract the shared piece into a third module both import, killing the cycle (best, and usually reveals a real layering flaw); late import — move the import inside the function that needs it, deferring resolution past init (pragmatic, hides the dependency); import the module, not the name — import a plus a.helper() at call time (cheapest, leaves the cycle in place).
Together these three options cover every real circular-import situation: without the restructure option you patch the symptom and the architectural flaw stays; without knowing the late-import option you might be tempted to delete real behaviour trying to make the cycle go away.
a.py has from b import render at the top; b.py has from a import config at the top. Imports explode. Changing b.py to plain import a and reading a.config inside functions fixes it. Why does that work?
Import-time side effects, main, and the -m flag
Everything above compounds into one rule: module level is for definitions, not actions. A module-level db = psycopg.connect(...) runs during import — in every process that transitively touches the module, including the test runner that just wanted a constant, at a moment when config may not be loaded and event loops do not exist. The lifecycle home for that work is an explicit startup hook — the lifespan pattern from the web-services unit — not import time. The smell test: importing any module of yours should be free of I/O, and python -X importtime -c "import yourpkg" is the profiler that proves it.
Last piece: how a file becomes a program. python pkg/tool.py runs the file as __main__ with no package context — __package__ is empty, so from . import db raises ImportError: attempted relative import with no known parent package, and sys.path[0] becomes pkg/, re-arming the shadowing gun. python -m pkg.tool instead imports pkg properly (running its __init__.py), executes tool as __main__ with package context, and puts the current directory — not the script dir — at sys.path[0]. Scripts that live inside packages need -m; that is also why console-script entry points (next lesson) generate shims that import your module instead of executing the file.
- 01Walk the full path of import app.handlers on a cold process — and name the ordering detail that decides whether a circular import survives.
- 02Why did a single type hint cost 14 seconds of startup, and what is the disciplined fix — including how -m fits the picture?
An import is a two-speed operation: first time, the machinery — finders on sys.meta_path locate the code, a loader executes the module body once into a fresh namespace; every time after, a dict hit in sys.modules. That cache makes modules process-global singletons (mutation is visible to all importers — the monkeypatching mechanism) and makes module-level code run-once process init, which is why one type-hint import of pandas+torch cost the Hook service 14 seconds in every worker, and why the fix was TYPE_CHECKING plus a lazy import, not faster code. The pre-exec registration ordering — module enters sys.modules empty, body runs second — decides circular-import fate: import x survives by deferring attribute access to call time, from x import name dies on the partially initialized module, and the fixes rank restructure over late-import over import-module-not-name. sys.path[0] is the script dir, so a local random.py shadows the stdlib for the whole process; packages execute __init__.py on import, so heavy inits tax every consumer; missing __init__.py silently makes a namespace package — a feature only when you deliberately split one namespace across distributions. And a file inside a package becomes a program with python -m pkg.tool, which preserves package context — running the file directly strips it and re-arms every trap above. Now when you see a 10-second startup, a shadowed stdlib attribute deep in a third-party traceback, or an ImportError about a partially initialized module, you know exactly which layer to look at first and which of the three circular-import fixes to reach for.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.