People often say that Python "caches imports." That sentence predicts the common case, but it hides the object being cached, the point at which it enters the cache, and the several other caches involved in finding and compiling modules.

The central object is sys.modules: a mutable mapping from fully qualified module names to module objects. Import normally consults it before asking finders to locate code. A hit returns an existing object; it does not rerun the file. That rule explains singleton-like module state, import-time side effects, circular imports, reload surprises, and many tests that pass alone but fail in a suite.

This tutorial makes those consequences observable. Each experiment is a standalone script or a small shell session.

Version note. The experiments were run with CPython 3.14.7 and use APIs available in Python 3.10+. The documented import protocol and sys.modules behavior are Python guarantees. Object addresses, bytecode files, timing, and details of CPython's import lock are implementation and version details.

Experiment 1: the cache stores a module object

Create a temporary module, put its directory on the import path, and import it twice:

import importlib
import sys
import tempfile
from pathlib import Path

with tempfile.TemporaryDirectory() as directory:
    path = Path(directory)
    (path / "counter.py").write_text(
        'print("executing counter")\nvalue = []\n', encoding="utf-8"
    )
    sys.path.insert(0, directory)
    try:
        first = importlib.import_module("counter")
        first.value.append("kept")
        second = importlib.import_module("counter")

        print(first is second)
        print(second.value)
        print(sys.modules["counter"] is first)
    finally:
        sys.path.remove(directory)
        sys.modules.pop("counter", None)

The module prints executing counter once, followed by True, ['kept'], and True. The source file is not the cached unit. Python cached the module object created to hold that file's global namespace. Both local names refer to that same object, so mutation through one is visible through the other.

Python guarantee. Import first checks the fully qualified name in sys.modules. If its value is a module object, that object satisfies the import. Code execution is skipped.

This is why a module-level registry or client can behave like process-global state. It is not because modules are a special singleton class. It is because normal import routes one name to one cached object within an interpreter.

Experiment 2: import and name binding are separate

The import statement both invokes import machinery and binds names. sys.modules belongs to the first operation, not the second:

import json.decoder
import sys

print("json" in globals())
print("json" in sys.modules)
print("json.decoder" in sys.modules)
print(sys.modules["json"].decoder is sys.modules["json.decoder"])

All four lines print True. Importing a dotted name ensures that intermediate packages are loaded. It also maintains an invariant: after spam.foo is imported, sys.modules['spam'].foo is the object in sys.modules['spam.foo'].

Now compare two binding forms:

Pyodide / WebAssembly
import math as arithmetic
from math import sqrt

print("[state] aliased module name:", arithmetic.__name__)
print("[result] square root of 81:", sqrt(81))
print("[check] original math name is global:", "math" in globals())

This prints math, 9.0, and False. The cache key is still math; aliases and imported attributes only control names in the importing namespace. Deleting arithmetic from globals() would not remove sys.modules['math'], and deleting the cache entry would not erase existing local references.

The loading timeline

On a cache miss, the import system broadly does this:

  1. Ask meta path finders for a module spec.
  2. Create a module object, usually through the spec's loader.
  3. Initialize import attributes such as __spec__ and __loader__.
  4. Insert the module into sys.modules.
  5. Ask the loader to execute code in the module namespace.
  6. Return the object currently stored under that cache key.

Step 4 deliberately precedes step 5. A module is visible before it is fully initialized. Without that early insertion, a module importing itself indirectly could create objects forever.

Experiment 3: observe partial initialization

This module looks itself up while its body is still running:

import importlib
import sys
import tempfile
from pathlib import Path

source = '''
import sys

current = sys.modules[__name__]
print(current.__name__)
print(hasattr(current, "ready"))
ready = True
'''

with tempfile.TemporaryDirectory() as directory:
    Path(directory, "starting.py").write_text(source, encoding="utf-8")
    sys.path.insert(0, directory)
    try:
        module = importlib.import_module("starting")
        print(module.ready)
    finally:
        sys.path.remove(directory)
        sys.modules.pop("starting", None)

The output is starting, False, then True. The first two lines run inside the module body: the object already exists, but execution has not reached ready = True.

This is the mechanism behind the familiar circular-import error about a "partially initialized module." The cache is doing its job. The dependency graph asks for a name before the node defining it has finished.

Experiment 4: a circular import fails at the edge

Build two modules:

import importlib
import sys
import tempfile
from pathlib import Path

with tempfile.TemporaryDirectory() as directory:
    root = Path(directory)
    (root / "alpha.py").write_text(
        "from beta import beta_value\nalpha_value = 'A'\n", encoding="utf-8"
    )
    (root / "beta.py").write_text(
        "from alpha import alpha_value\nbeta_value = 'B'\n", encoding="utf-8"
    )
    sys.path.insert(0, directory)
    try:
        try:
            importlib.import_module("alpha")
        except ImportError as error:
            print(type(error).__name__)
            print("partially initialized" in str(error))
        print("alpha" in sys.modules)
        print("beta" in sys.modules)
    finally:
        sys.path.remove(directory)
        sys.modules.pop("alpha", None)
        sys.modules.pop("beta", None)

On CPython 3.14 this prints ImportError, True, False, False. Do not assert the exact diagnostic text across implementations. The guaranteed mechanism is more useful: alpha is cached, starts beta, and beta requests alpha_value before alpha assigns it.

The usual repair is architectural. Move shared definitions to a lower-level module, reverse a dependency through a callback or protocol, or delay a narrowly scoped import until the operation that needs it. Moving every import inside functions can suppress the symptom while preserving an incoherent graph, so use that technique intentionally.

Failed imports and surviving side effects

When execution raises, import removes the failing module's cache entry. It does not roll back arbitrary work already performed, nor does it remove modules successfully imported as side effects.

Experiment 5: failure is not a transaction

import importlib
import sys
import tempfile
from pathlib import Path

with tempfile.TemporaryDirectory() as directory:
    root = Path(directory)
    marker = root / "marker.txt"
    (root / "helper.py").write_text("value = 42\n", encoding="utf-8")
    (root / "broken.py").write_text(
        f"import helper\nopen({str(marker)!r}, 'w').write('made')\nraise RuntimeError('boom')\n",
        encoding="utf-8",
    )
    sys.path.insert(0, directory)
    try:
        try:
            importlib.import_module("broken")
        except RuntimeError:
            pass
        print("broken" in sys.modules)
        print("helper" in sys.modules)
        print(marker.read_text(encoding="utf-8"))
    finally:
        sys.path.remove(directory)
        sys.modules.pop("broken", None)
        sys.modules.pop("helper", None)

The output is False, True, and made. Only the failing cache entry is cleaned up. A retry can run the side effect again. This is a strong reason to keep import-time work limited to definitions and cheap deterministic initialization. Opening network connections, registering duplicate handlers, migrating schemas, or writing files at import time creates retries that are difficult to reason about.

Experiment 6: deleting a cache entry splits identity

Because sys.modules is writable, you can force a later import to create a new object:

import importlib
import sys
import tempfile
from pathlib import Path

with tempfile.TemporaryDirectory() as directory:
    Path(directory, "identity.py").write_text("token = object()\n", encoding="utf-8")
    sys.path.insert(0, directory)
    try:
        old = importlib.import_module("identity")
        del sys.modules["identity"]
        new = importlib.import_module("identity")

        print(old is new)
        print(old.token is new.token)
        print(sys.modules["identity"] is new)
    finally:
        sys.path.remove(directory)
        sys.modules.pop("identity", None)

It prints False, False, and True. The old object remains alive because old references it. Any classes, exceptions, registries, or sentinels exported by that object also remain distinct. An instance of old.Widget is not an instance of a newly created new.Widget, even if both classes came from identical source text.

Assigning None is different from deleting the key:

Pyodide / WebAssembly
import sys

sys.modules["definitely_blocked"] = None
try:
    __import__("definitely_blocked")
except ModuleNotFoundError as error:
    print("[error] blocked module name:", error.name)
finally:
    del sys.modules["definitely_blocked"]

This deterministically blocks that import while the sentinel is present. Direct mutation is a public capability, but it is sharp. Prefer unittest.mock.patch.dict() or pytest's monkeypatch in tests so restoration is guaranteed.

Experiment 7: reload reuses identity but keeps the namespace

importlib.reload(module) reruns module code using its spec and loader. It retains the module object and its dictionary; names not overwritten by the new execution can survive.

import importlib
import sys
import tempfile
from pathlib import Path

with tempfile.TemporaryDirectory() as directory:
    file = Path(directory, "settings.py")
    file.write_text("answer = 1\nlegacy = 'still here'\n", encoding="utf-8")
    sys.path.insert(0, directory)
    try:
        module = importlib.import_module("settings")
        file.write_text("answer = 200\n", encoding="utf-8")
        importlib.invalidate_caches()
        same = importlib.reload(module)

        print(same is module)
        print(module.answer)
        print(module.legacy)
    finally:
        sys.path.remove(directory)
        sys.modules.pop("settings", None)

This prints True, 200, and still here on the tested interpreter. The changed file size avoids timestamp-based bytecode caches mistaking a rapid edit for unchanged source on filesystems with coarse timestamps. The retained dictionary is documented behavior.

Reload also does not update objects copied elsewhere:

import importlib
from decimal import Decimal

old_decimal = Decimal
import decimal
importlib.reload(decimal)

print(Decimal is old_decimal)
print(Decimal is decimal.Decimal)

The first line must be True: reload cannot rebind a name in your module. The second line is loader-dependent in general; standard-library extension machinery may retain object identities. For a pure Python module that recreates a class, a previous from module import Class remains bound to the old class.

Tool note. Reload is useful in interactive development, but it is not a general hot-deployment protocol. Existing instances, copied names, threads, callbacks, and extension modules can retain old state. Restarting the process gives a much cleaner boundary.

Experiment 8: finder caches are not the module cache

importlib.invalidate_caches() asks finders on sys.meta_path to invalidate internal search caches. It does not remove loaded modules from sys.modules:

import importlib
import math
import sys

before = sys.modules["math"]
importlib.invalidate_caches()
after = importlib.import_module("math")

print(before is after)

This prints True. Call invalidation when a process creates module files after startup and a finder may have cached directory information. It cannot make a changed, already imported module run again.

There is also a bytecode cache, usually under __pycache__. A .pyc can avoid recompiling source, but it is not the reason repeated import returns the same module. In a fresh process, Python may load cached bytecode and still create and execute a new module object. Keep the layers distinct:

  • sys.modules caches loaded module objects by name;
  • finders may cache information about where modules can be found;
  • bytecode caches store compiled code across processes.

Clearing one layer does not imply clearing the others.

Practical decisions

Use import caching as a stable part of your design, not as an invisible convenience.

  • Put definitions, constants, and cheap deterministic assembly at module scope.
  • Put resource acquisition behind explicit functions, context managers, or application lifecycle hooks.
  • Treat module-level mutable state as process-global unless you deliberately provide reset or replacement APIs.
  • Break circular dependencies by redrawing ownership before reaching for local imports.
  • For optional dependencies, catch ModuleNotFoundError narrowly and inspect error.name; do not hide failures inside an installed package.
  • In tests, patch the name the consumer looks up. Replacing sys.modules only affects future imports, not references already bound elsewhere.
  • Use a subprocess when the behavior under test is first-import behavior. It provides a fresh interpreter and avoids reconstructing an import graph by hand.
  • Prefer process restarts to reload for production code changes.

Common failure modes

"I changed the file but Python still uses the old value." The module object is already in sys.modules. Finder invalidation does not rerun it. Restart, or use reload while accepting reload's limits.

"Deleting sys.modules[name] reset everything." It reset one lookup entry. Other modules may hold the old module, functions, classes, or imported attributes. You may now have two live worlds.

"The circular import worked after I reordered two lines." You changed which attributes existed during partial initialization. The graph remains order-sensitive and can break after another import is added.

"An optional import swallowed a real bug." except ImportError around a large import also catches errors raised by that package's own imports. Keep the protected statement narrow and verify the missing module name.

"A failed import is safe to retry." The failing cache entry is removed, but external side effects and successfully imported dependencies remain.

Exercises

  1. Modify Experiment 1 so two aliases import the same module. Prove that aliases never become cache keys.
  2. Add prints before and after each statement in the circular pair. Draw the exact partial namespaces at failure.
  3. Define a class in the reload experiment, retain an old instance, and predict both isinstance results after reload.
  4. Write a test that launches two subprocesses and proves that each process executes a module's top-level code once.
  5. Implement a context manager that temporarily places a types.ModuleType object in sys.modules and always restores the previous entry.

Keep this model

Import is a protocol over names and module objects. sys.modules is its first lookup and its recursion guard. A module enters that mapping before execution finishes, remains there after successful loading, and is normally returned unchanged on every later import in that interpreter.

That model is precise enough to distinguish object caching from finder and bytecode caches. It predicts why side effects run once, why circular imports see incomplete namespaces, why deletion can duplicate types, and why reload cannot reconstruct a clean process. When import behavior surprises you, inspect the fully qualified name, the current cache entry, and the references that have already escaped.

Primary sources