Python6 min read

Python Imports Execute a Cached Module Graph

How sys.modules, package initialization, from-import bindings, cycles, and reload shape import behaviour.

  • imports
  • modules
  • packages

An import statement is both a name-binding operation and a request to locate, create, and possibly execute a module. Keeping those phases separate explains why repeated imports are cheap and why circular imports can expose partially initialised state.

The cache is populated before execution finishes

For a normal source module, the import machinery checks sys.modules, finds a module specification, creates a module object, places it in sys.modules, and asks the loader to execute it. Inserting the object before execution prevents recursive imports from creating a second copy.

If module execution fails, the failing entry is normally removed from sys.modules. Modules successfully loaded as side effects can remain cached.

import sys
import reports

assert sys.modules['reports'] is reports

The cache key is the fully qualified module name. Importing the same file under different names through path manipulation can create distinct module objects and duplicate global state.

Import forms bind different local names

import settings binds the module object. from settings import timeout looks up an attribute and binds that object directly in the importing namespace.

from settings import timeout
import settings

settings.timeout = 30
print(timeout)

The from binding is live: because it remembers the source module and attribute name, the print observes 30 after reassignment. It behaves like an ES module import rather than an ordinary Python assignment.

Cycles expose incomplete namespaces

If a imports b while b imports a, the second import finds a in sys.modules. That object may not yet contain names assigned later in a.py. Accessing one too early raises an error describing a partially initialised module.

Cycles are easier to tolerate when modules define functions and classes before doing cross-module work, or when an import is moved inside a function that runs after startup. A shared dependency module is often clearer than carefully arranging side effects.

Package __init__.py participates in the graph. Importing package.child first initialises package, then loads the child and normally binds it as an attribute of the parent package.

Reload is not a clean restart

importlib.reload(module) creates a new module object, replaces the entry in sys.modules, and executes code into a fresh namespace. Existing references to the old module continue pointing at the old object, while later imports receive the new one.

Reload can be useful in interactive development, but application correctness should not depend on it resetting all state. Explicit state containers and restartable processes provide clearer lifecycle boundaries.

Imports are predictable when modules minimise top-level side effects, use stable qualified names, and expose functions that perform work after the dependency graph has finished initialising.