Modules and the Import System

Updated

September 7, 2026

Modules and the Import System

After reading this chapter, you will master Python’s module architecture, trace the import resolution pipeline through sys.modules and sys.path, dynamically load modules using importlib, and diagnose and resolve circular import deadlocks.

Mental model

In Python, a module is simply a .py source file executed as a distinct namespace object (types.ModuleType). When an import foo statement executes, CPython traverses a four-stage pipeline:

import foo
   │
   ▼
[ 1. Check sys.modules Cache ]
   │
   ├─ If cached ───────────────▶ Return existing module reference immediately
   │
   ▼ (Not cached)
[ 2. Finders (sys.meta_path) ] ──▶ Search sys.path directories for 'foo.py'
   │
   ▼ (Found file)
[ 3. Loaders (FileLoader) ]    ──▶ Compile bytecode, allocate empty ModuleType
   │
   ▼
[ 4. Execute Module Code ]     ──▶ Run top-level statements, populate __dict__,
                                    cache in sys.modules, bind name in caller

Because modules are cached in sys.modules upon first import, subsequent imports of the same module across threads or files return the cached instance with zero disk I/O or recompilation.


Minimal example

Save as import_internals.py:

# import_internals.py
import importlib
import sys
from types import ModuleType

def inspect_import_cache(module_name: str) -> None:
    print(f"Is '{module_name}' cached in sys.modules? {module_name in sys.modules}")

def dynamically_load_module(module_name: str) -> ModuleType:
    """Programmatically import a module by string name using importlib."""
    print(f"\nDynamically loading '{module_name}' via importlib...")
    mod = importlib.import_module(module_name)
    print(f"Module file path : {getattr(mod, '__file__', 'built-in')}")
    print(f"Module docstring : {mod.__doc__[:40] if mod.__doc__ else 'None'}...")
    return mod

def main() -> None:
    inspect_import_cache("json")
    
    # Dynamic import
    json_mod = dynamically_load_module("json")
    inspect_import_cache("json")

    # Use dynamically loaded module
    serialized = json_mod.dumps({"status": "healthy", "code": 200})
    print(f"Serialized output: {serialized}")

if __name__ == "__main__":
    main()

Run via uv run python import_internals.py:

Is 'json' cached in sys.modules? False

Dynamically loading 'json' via importlib...
Module file path : .../lib/python3.14/json/__init__.py
Module docstring : JSON (JavaScript Object Notation) <http:...
Is 'json' cached in sys.modules? True
Serialized output: {"status": "healthy", "code": 200}

Worked examples

Case 1: Dynamic Driver Registry with importlib

In cloud automation and backend platforms, you frequently need to load storage, networking, or database drivers specified by external configuration strings:

# driver_loader.py
import importlib
from typing import Any

class DriverLoader:
    @staticmethod
    def load_formatter(format_name: str) -> Any:
        # Standard library formatters mapping
        known_modules = {
            "json": "json",
            "csv": "csv",
        }
        if format_name not in known_modules:
            raise ValueError(f"Unsupported driver: {format_name}")

        mod = importlib.import_module(known_modules[format_name])
        return mod

if __name__ == "__main__":
    loader = DriverLoader()
    driver = loader.load_formatter("json")
    data = {"metric": "cpu", "value": 85.5}
    print(f"Driver loaded dynamically: {driver.__name__}")
    print(f"Formatted: {driver.dumps(data)}")

Run:

uv run python driver_loader.py

Output:

Driver loaded dynamically: json
Formatted: {"metric": "cpu", "value": 85.5}

Case 2: Diagnosing and Breaking Circular Imports

A circular import occurs when Module A imports Module B at top-level, but Module B imports Module A before Module A has finished executing its definition pass.

Circular Import Deadlock:
  module_a.py ──(imports)──▶ module_b.py
       ▲                           │
       │                           ▼
       └────────(imports)──────────┘

When Python hits from module_a import Service inside module_b, module_a is still being executed; Service does not yet exist in module_a.__dict__, raising: ImportError: cannot import name 'Service' from partially initialized module 'module_a'.

The Fixes:

  1. Move shared domain types/interfaces to a third, leaf module (types.py or models.py) imported by both.
  2. Use local imports: Import the dependency inside the specific function that uses it, rather than at module top-level.
  3. Use typing.TYPE_CHECKING: If the import is only needed for type annotations, gate it behind if TYPE_CHECKING:.
# circular_fix_pattern.py
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    # Only imported during static analysis (mypy/pyright), NEVER at runtime!
    from collections.abc import Sequence

def process_batch(items: "Sequence[str]") -> int:
    return len(items)

if __name__ == "__main__":
    print("Function defined cleanly without circular runtime dependency.")
    print("Result:", process_batch(["task-1", "task-2"]))

Run:

uv run python circular_fix_pattern.py

Output:

Function defined cleanly without circular runtime dependency.
Result: 2

Case 3: The if __name__ == "__main__": Boundary

When a Python file is invoked directly (python script.py), CPython assigns its __name__ attribute to "__main__". When imported by another file (import script), __name__ is set to the module’s file stem ("script").

# dual_mode_module.py
def calculate_throughput(bytes_transferred: int, duration_seconds: float) -> float:
    """Pure library function: usable when imported."""
    if duration_seconds <= 0:
        raise ValueError("Duration must be greater than zero.")
    return bytes_transferred / duration_seconds

def _cli_entrypoint() -> None:
    """CLI testing routine: runs ONLY when executed directly."""
    sample_bytes = 104_857_600  # 100 MB
    duration = 2.5
    rate_mb = calculate_throughput(sample_bytes, duration) / (1024 * 1024)
    print(f"[CLI Runner] Transfer rate: {rate_mb:.2f} MB/s")

if __name__ == "__main__":
    _cli_entrypoint()

Run:

uv run python dual_mode_module.py

Output:

[CLI Runner] Transfer rate: 40.00 MB/s

When another module runs from dual_mode_module import calculate_throughput, _cli_entrypoint() will not execute.


Pitfalls

Pitfall 1: Standard Library Shadowing

Naming a local script after a standard library module (e.g. creating math.py, random.py, test.py, or json.py in your project folder) breaks the import system:

Your Directory:
├── math.py          (Your script)
└── test_math.py     (import math -> loads YOUR math.py instead of CPython's!)

Because CPython checks the current working directory first in sys.path[0], your local file shadows the standard library module, causing cryptic AttributeError: module 'math' has no attribute 'sqrt' errors.


Exercises

  1. Print all paths in sys.path and inspect where Python looks for modules on your machine.
  2. Write a function that accepts a module name as a string, checks whether it is already loaded in sys.modules, and reloads it using importlib.reload() if present.
  3. Create a simulated two-module circular import and resolve it using local function-level imports.
  4. Use __all__ to restrict the symbols exported when a consumer executes from my_module import *.

Further reading

  • Python Documentation: The Import System (sys.meta_path, Finders, Loaders).
  • Python Standard Library: importlib and sys.modules.
  • PEP 302: New Import Hooks.