Python modules aren’t just files; they’re objects, and the import system is a highly optimized, stateful service that manages a cache of these loaded objects.

Let’s watch it happen. Imagine you have this file structure:

my_project/
├── main.py
└── utils/
    ├── __init__.py
    └── helpers.py

And the contents:

utils/helpers.py:

print("Loading helpers module...")
version = "1.0"

def greet(name):
    return f"Hello, {name}! This is version {version}."

utils/__init__.py:

print("Loading utils package...")
from . import helpers

main.py:

print("Starting main script...")
import utils

print(utils.helpers.greet("Alice"))
print("Finished main script.")

When you run python main.py, here’s the output you’ll see:

Starting main script...
Loading utils package...
Loading helpers module...
Hello, Alice! This is version 1.0.
Finished main script.

Notice the order. utils loads first, and then helpers loads because utils/__init__.py explicitly imports it. If you ran import utils.helpers directly in main.py, the output would be:

Starting main script...
Loading helpers module...
Hello, Alice! This is version 1.0.
Finished main script.

The utils package itself wouldn’t show its "Loading utils package…" message because it wasn’t directly imported.

The import system’s core is sys.modules. This dictionary is Python’s module cache. Before Python even looks for a module file, it checks sys.modules. If the module name is a key, Python returns the already loaded module object from the cache. This is why import somemodule only executes the code in somemodule.py once per Python process, even if you import it a dozen times.

Here’s how it works internally:

  1. sys.modules Check: Python looks for the module name (e.g., "utils", "utils.helpers") in sys.modules.
  2. Finders and Loaders: If not found, Python consults a list of "meta path finders" (usually sys.meta_path). These finders are responsible for locating the module. A common finder is importlib.machinery.PathFinder, which searches directories listed in sys.path.
  3. Module Creation: Once a finder locates the module’s source file (or compiled bytecode), it uses a "loader" (like importlib.machinery.SourceFileLoader) to create a new module object.
  4. Execution: The loader then executes the module’s code. The results of this execution (definitions of functions, classes, variables) are bound to the module object.
  5. Caching: Finally, the newly created and executed module object is added to sys.modules under its name, and returned to the importer.

The sys.path variable is a list of directories where Python looks for modules. When you import my_module, Python iterates through sys.path to find my_module.py or a my_module directory (for packages). The current directory is implicitly included if it’s not empty.

You can inspect sys.path like this:

import sys
print(sys.path)

This will show you a list of paths, e.g., ['/path/to/your/project', '/usr/lib/python3.10', '/usr/lib/python3.10/lib-dynload', ...].

The mechanics of how a package like utils is loaded are particularly interesting. When you import utils, Python looks for utils/__init__.py. If it finds it, it executes that file. The contents of __init__.py define what gets imported when you import utils. In our example, from . import helpers inside utils/__init__.py causes helpers to be loaded as part of the utils package loading. Without utils/__init__.py, utils would be treated as a regular module, and import utils.helpers would fail because utils wouldn’t be recognized as a package containing other modules.

A common point of confusion is how relative imports (from . import helpers) work within packages. These are resolved based on the package the importing module belongs to, not directly on sys.path. The . signifies the current package, .. signifies the parent package, and so on. This mechanism is crucial for building larger, modular Python applications.

When Python encounters an import statement, it doesn’t just read a file; it performs a complex lookup, retrieval, compilation (if necessary), execution, and caching process managed by sys.modules and a set of finders and loaders.

The most surprising thing most people don’t realize is that the import statement itself is just syntactic sugar for calling __import__ or, more commonly now, using the importlib machinery. The entire process is highly programmable, which is how tools like pytest can dynamically load test modules or how frameworks can manage plugin systems.

The next hurdle is understanding how to create custom import hooks to load modules from non-standard locations or formats.

Want structured learning?

Take the full Python course →