Inside Python’s Modules and Packages: The Machinery Behind import

A reference-grade walk through Python’s import system: path resolution, package types, the spec → finder → loader pipeline, and the hooks that let you bend it

Software Engineering
Author

Imad Dabbura

Published

February 9, 2024

Modified

July 6, 2026

evergreen

Introduction

Every Python program you have ever written begins with import. You type import pandas as pd or from mypackage.utils import clean, the name resolves, and you move on. But what actually happens in the fraction of a second between that statement and the module being ready to use? Where does Python look? Why can two directories with the same name merge into a single package? How does import tell a built-in module apart from a .py file on disk from a compiled extension, and why can you intercept that decision to, say, install a missing package on the fly?

The import system is one of the most powerful and least understood parts of Python. It is not magic: it is a small, well-defined pipeline of objects (finders, loaders, and specs) that you can inspect, extend, and replace at runtime. Understanding it is the difference between someone who uses imports and someone who can debug a ModuleNotFoundError that makes no sense, build a plugin system, or reason about why a reload left their program in a broken state.

By the end of this post, you will be able to:

  1. Trace exactly what happens when Python executes import x, step by step.
  2. Explain the difference between a module and a package, and between regular and namespace packages, and why both exist.
  3. Read a ModuleSpec and follow the finder → loader → module pipeline that produces it.
  4. Extend the import system yourself: trace imports, lazy-load expensive modules, and hook sys.meta_path.
  5. Avoid the classic traps: reload “zombies”, brittle absolute imports, and cache corruption.

We start with the ten-thousand-foot view of what import does, then work downward: the object model of modules and packages, how Python finds them (sys.path), how it loads them (specs, finders, loaders), how source gets compiled and cached, the hooks that let you bend the machinery, running packages as programs, and finally the failure modes worth knowing. Every claim is backed by a live snippet you can run yourself.

NoteVersions and environment

The examples were run on CPython 3.14. The import system has been stable since its “reboot” in Python 3.3–3.4 (PEP 451), so everything here applies to any modern Python 3. Output that shows filesystem paths or the contents of sys.meta_path is environment-specific. Yours will differ in the details, not the shape.

Setup: a scratch workspace for the live demos (click to expand)
import sys, os, tempfile, textwrap, importlib, importlib.util, types, builtins

WORKSPACE = tempfile.mkdtemp(prefix="import-demo-")
sys.path.insert(0, WORKSPACE)

def write_module(relpath, source):
    """Write a module (or package file) under WORKSPACE; return its path."""
    path = os.path.join(WORKSPACE, relpath)
    os.makedirs(os.path.dirname(path) or WORKSPACE, exist_ok=True)
    with open(path, "w") as f:
        f.write(textwrap.dedent(source).lstrip())
    return path

1. What import Actually Does

Before dissecting any individual piece, hold the whole pipeline in your head. When you write import spam, Python runs four steps in order:

  1. Search: walk a chain of finders, asking each “do you know how to locate spam?” The winner returns a spec: metadata describing where the module is and how to load it.
  2. Create: build an empty module object (types.ModuleType) and set its attributes (__name__, __file__, __spec__, …).
  3. Execute: run the module’s code with the module’s own __dict__ as its global namespace. This is what populates it with functions, classes, and variables.
  4. Bind: bind the resulting module object to a name in the scope that issued the import.

Two facts do most of the work here. First, the finished module is cached in sys.modules before it is executed, and every later import of the same name is a plain dictionary lookup, not a re-execution:

import json
first = sys.modules["json"]
import json                 # a second import is a dict lookup, not a re-run
first is sys.modules["json"]
True

sys.modules is why importing a module a thousand times costs almost nothing, and why a module’s top-level code runs exactly once per interpreter session. Second, “executing the module” is literally running its file top to bottom. There is no separate “definition mode.” A module is the accumulated side effects of running its source.

import is not a keyword that reads a file. It is a cache lookup that, on a miss, runs a find → create → execute → bind pipeline and memoizes the result.

2. Modules and Packages: The Object Model

2.1 A module is just a namespace object

A module is not a special language construct; it is an ordinary object of type types.ModuleType whose attributes live in its __dict__. Accessing json.loads is a dictionary lookup in json.__dict__; setting an attribute on a module writes to that same dict:

import json
json.custom = 123                 # identical to json.__dict__["custom"] = 123
type(json), json.__name__, "loads" in json.__dict__, json.__dict__["custom"]
(module, 'json', True, 123)

module.x = 10 and module.__dict__["x"] = 10 are the same operation. This is why a module can play so many roles at once: a namespace, an execution environment for statements, and a container of globals. Every module carries a handful of dunder attributes that the import system sets on it:

Attribute Meaning
__name__ Fully-qualified module name ("json", "os.path")
__file__ Path to the source file, if it has one (built-ins don’t)
__doc__ The module docstring
__dict__ The module’s namespace: everything defined at top level
__package__ The package this module belongs to (see §2.2)
__path__ Present only on packages: the search path for submodules
__spec__ The ModuleSpec used to load it (see §4)
__loader__ The loader object that executed it

2.2 A package is a module with a __path__

A package is simply a module that has a __path__ attribute. __path__ is a list of directories to search when importing that package’s submodules; it is to a package what sys.path is to the interpreter. A plain module has no __path__; a package does, and that is what lets import foo.bar know where to find bar.

All packages are modules, but not all modules are packages; only packages have __path__.

For __package__: a package’s __package__ equals its own __name__; a top-level module’s is the empty string; a submodule’s is its parent package’s name.

2.3 Regular vs. namespace packages

A regular package is a directory containing an __init__.py. Importing it runs that __init__.py, and its namespace becomes the package. Crucially, when the finder locates a regular package it stops searching: the first __init__.py of that name on sys.path wins.

A namespace package is a directory without __init__.py (PEP 420). It behaves completely differently during search: the finder does not stop at the first match. It scans all of sys.path, collecting every directory of that name, and, if it never finds a regular package with that name, fuses the collected directories into a single package whose __path__ spans every one of them.

Regular package Namespace package
Marker Has __init__.py No __init__.py
Search behavior Stops at first match Scans all of sys.path, collects every match
__init__.py runs Yes N/A
__path__ Single directory _NamespacePath spanning multiple directories
Priority Always wins over a namespace package of the same name Used only if no regular package shadows it
Use case Normal, self-contained package One logical package split across installs (plugin ecosystems)

The same package name can pull submodules from completely different directories. Let’s prove it. We create two separate directories, each holding a pck/ folder with no __init__.py, and put a different submodule in each:

# Two directories, each with a `pck/` folder that has NO __init__.py -> namespace pkg
write_module("locA/pck/mod.py",  "X = 100")
write_module("locB/pck/test.py", "X = 200")
sys.path[:0] = [f"{WORKSPACE}/locA", f"{WORKSPACE}/locB"]

from pck.mod import X as from_A
from pck.test import X as from_B
from_A, from_B, list(sys.modules["pck"].__path__)
(100,
 200,
 ['/var/folders/hn/9dlqvjvx427_3m7wj0vxmpx80000gn/T/import-demo-v_vgrtrz/locA/pck',
  '/var/folders/hn/9dlqvjvx427_3m7wj0vxmpx80000gn/T/import-demo-v_vgrtrz/locB/pck'])

The single name pck now resolves to a package whose __path__ lists both temp directories, and pck.mod and pck.test were imported from different places on disk. This is exactly how large namespaced ecosystems (like the google.* cloud libraries) let independently-installed distributions contribute modules under one shared name.

WarningRegular packages win and shadow the rest

Python scans the entire sys.path before concluding a package is a namespace package. If any directory on the path contains that name with an __init__.py, the regular package wins and every namespace candidate is discarded. A stray __init__.py in the wrong place can silently “capture” a name you expected to be a namespace package.

2.4 What __init__.py is for

Beyond marking a directory as a regular package, __init__.py runs the first time the package is imported, which makes it the natural place to shape the package’s public surface. Common uses:

  • Re-export the useful names from submodules so callers write from mypkg import Thing instead of from mypkg.internal.things import Thing.
  • Assemble __all__ from submodules to control from mypkg import *, e.g. __all__ = submod_a.__all__ + submod_b.__all__ (import the submodules first so their __all__ exists).
  • Initialize package-level state, or occasionally monkeypatch another module.

Keep __init__.py cheap: because it runs on first import, expensive work there is paid by every program that imports the package, whether or not it needs that work.

2.5 Dotted imports bind submodules to their parents

Importing foo.bar.baz imports foo, then foo.bar, then foo.bar.baz, in that order, caching each of the three separately in sys.modules. A subtle but important side effect: when a submodule is loaded, it is bound as an attribute on its parent package. After import os.path, the parent module os gains a path attribute pointing at the submodule:

import os.path
"os.path" in sys.modules, hasattr(os, "path"), os.path is sys.modules["os.path"]
(True, True, True)

This is why import os.path lets you write os.path.join(...) even though you only named os: the submodule attached itself to its parent during import.

3. Finding Modules: sys.path and the Search Space

For file-based imports, the search space is sys.path: an ordered list of locations (directories, zip files, .egg archives) that Python walks front to back, first match wins. If nothing matches, you get ModuleNotFoundError.

sys.path[:6]
['/var/folders/hn/9dlqvjvx427_3m7wj0vxmpx80000gn/T/import-demo-v_vgrtrz/locA',
 '/var/folders/hn/9dlqvjvx427_3m7wj0vxmpx80000gn/T/import-demo-v_vgrtrz/locB',
 '/var/folders/hn/9dlqvjvx427_3m7wj0vxmpx80000gn/T/import-demo-v_vgrtrz',
 '/opt/homebrew/Cellar/python@3.14/3.14.6/Frameworks/Python.framework/Versions/3.14/lib/python314.zip',
 '/opt/homebrew/Cellar/python@3.14/3.14.6/Frameworks/Python.framework/Versions/3.14/lib/python3.14',
 '/opt/homebrew/Cellar/python@3.14/3.14.6/Frameworks/Python.framework/Versions/3.14/lib/python3.14/lib-dynload']

Where do these entries come from? Python assembles sys.path at startup from several sources:

  • The script’s directory (or the current working directory in an interactive session), added at the front, which is why a local math.py shadows the standard library.
  • PYTHONPATH, if set, prepended to the front.
  • The standard library, located relative to the interpreter.
  • Site packages: where third-party installs live, added by site.py.
  • .pth files in site-packages: each line names a directory to append. Package managers and editable installs use this hook to inject paths.

Two values anchor the whole scheme. sys.prefix is where the Python installation lives (its landmark is the standard library’s os.py); sys.exec_prefix is where platform-specific compiled binaries live (landmark: the lib-dynload directory). A virtual environment is, at heart, a directory with its own sys.prefix and site-packages, sharing the base interpreter’s binary.

sys.prefix, sys.exec_prefix
('/Users/imad/dev/dl-venv', '/Users/imad/dev/dl-venv')

A few command-line switches and environment knobs shape all of this:

Flag / mechanism Effect on the import environment
python -m pkg.mod Run a module/package as __main__ (relative imports work; see §7)
python -S Skip site.py: no site-packages, no .pth processing
python -v / -vv Trace import activity (what Python tries, and where)
PYTHONPATH=… Prepend directories to sys.path
python -m venv env Create a virtual environment with its own prefix and site-packages
Tipsys.path is only half the story

sys.path answers “where on the filesystem do I look?”, but it is consulted by only one of the finders on sys.meta_path (the PathFinder). Built-in and frozen modules never touch sys.path. The real dispatch layer is sys.meta_path, which we get to in §4.4.

4. Loading Modules: Specs, Finders, and Loaders

4.1 The ModuleSpec: a module’s blueprint

Since the Python 3.4 import reboot (PEP 451), the central object of the import system is the ModuleSpec: the metadata bundle that says what a module is and how to load it. You can obtain one without importing anything via importlib.util.find_spec:

importlib.util.find_spec("sys")        # a built-in: origin is 'built-in', no file
ModuleSpec(name='sys', loader=<class '_frozen_importlib.BuiltinImporter'>, origin='built-in')
spec = importlib.util.find_spec("pandas")   # a source package on disk
spec.name, spec.origin, spec.submodule_search_locations
('pandas',
 '/Users/imad/dev/dl-venv/lib/python3.14/site-packages/pandas/__init__.py',
 ['/Users/imad/dev/dl-venv/lib/python3.14/site-packages/pandas'])

The contrast tells the story: sys is a built-in with origin='built-in' and no file; pandas is a source package whose origin is its __init__.py and whose submodule_search_locations is the __path__ it will receive. The spec’s important fields:

Field Meaning
name Fully-qualified module name
loader The object that will execute the module
origin Where it comes from ('built-in', a file path, …)
submodule_search_locations The future __path__: non-None only for packages
parent Enclosing package name
cached Location of the compiled .pyc, if any

4.2 The import algorithm, in pseudocode

Stripped to essentials, importing a not-yet-cached module looks like this:

import sys

def import_module(name):
    if name in sys.modules:              # 1. cache hit -> done
        return sys.modules[name]

    spec = find_spec(name)               # 2. search: finders -> spec (or ImportError)
    module = importlib.util.module_from_spec(spec)   # 3. create the empty module
    sys.modules[name] = module           # 4. cache BEFORE executing (circular-safe)
    spec.loader.exec_module(module)      # 5. execute the body into module.__dict__
    return sys.modules[name]             # 6. return cached (may differ if code swapped it)

Two details are worth internalizing. The module is cached before it is executed, so a circular import finds a partially-initialized module rather than looping forever. And the cached object is what’s returned: a module that reassigns sys.modules[__name__] to something else during execution can hand back a different object than the one that started loading.

4.3 Finders and loaders: create vs. execute are decoupled

The pipeline splits cleanly in two. A finder answers “can I locate this name?” and returns a spec. A loader does the work, and even that is split: create_module(spec) builds the empty module object, and exec_module(module) runs its code. importlib.util.module_from_spec gives you the created-but-not-executed module; nothing runs until you call exec_module:

write_module("heavy.py", """
    print(">>> heavy.py body is executing")
    VALUE = 42
""")
spec = importlib.util.find_spec("heavy")
heavy = importlib.util.module_from_spec(spec)   # created, but NOT executed
hasattr(heavy, "VALUE")                          # -> False: the body hasn't run
False
spec.loader.exec_module(heavy)                   # now the body runs
heavy.VALUE
>>> heavy.py body is executing
42

This decoupling is exactly what makes lazy loading (§6.3) possible: you can hold a fully-formed module object and defer the expensive exec_module until someone actually touches it.

4.4 sys.meta_path: the real controller

Every import is dispatched through sys.meta_path, an ordered list of meta path finders. Python asks each one, front to back, find_spec(name, path, target) until one returns a spec:

sys.meta_path
[<_distutils_hack.DistutilsMetaFinder at 0x1096146e0>,
 <_virtualenv._Finder at 0x109614590>,
 _frozen_importlib.BuiltinImporter,
 _frozen_importlib.FrozenImporter,
 _frozen_importlib_external.PathFinder,
 <six._SixMetaPathImporter at 0x10a44e900>]

The three that always ship with CPython are BuiltinImporter (compiled-in modules like sys), FrozenImporter (modules frozen into the binary, including importlib itself), and PathFinder, the only one that consults sys.path. Libraries and tools can prepend or append their own finders, so you may also see entries from setuptools or editable installs. Conceptually the search is just:

def find_spec(name, path=None, target=None):
    for finder in sys.meta_path:
        spec = finder.find_spec(name, path, target)
        if spec is not None:
            return spec
    raise ModuleNotFoundError(name)

PathFinder in turn delegates to sys.path_hooks: each sys.path entry is handed to these hooks to produce a path-specific finder (a directory gets a FileFinder; a .zip gets a zipimporter). The results are memoized in sys.path_importer_cache.

sys.path_hooks
[zipimport.zipimporter,
 <function _frozen_importlib_external.FileFinder.path_hook.<locals>.path_hook_for_FileFinder(path)>]

This layered design (meta_pathPathFinderpath_hooks → per-entry finder → loader) is the seam that makes §6 possible.

5. Compilation and the .pyc Cache

When a loader executes a source module, it doesn’t interpret the .py text directly: it first compiles the source to bytecode (a code object), then executes that. Compilation isn’t free, so CPython caches the result: the bytecode is written to a .pyc file inside a __pycache__/ directory next to the source (PEP 3147). On the next import, if the cache is still valid, Python skips compilation and loads the bytecode directly, which is why the second startup of a program that imports a big library is noticeably faster than the first.

Figure 1: A .pyc file is a small header followed by the marshalled code object. The header holds a magic number (a version tag; bytecode from a different Python version is rejected), plus the source’s mtime and size. On import, Python compares those against the current source file; if either changed, the cache is stale and the module is recompiled. (Slide from David Beazley’s “Modules and Packages: Live and Let Die!”)

Figure 1: A .pyc file is a small header followed by the marshalled code object. The header holds a magic number (a version tag; bytecode from a different Python version is rejected), plus the source’s mtime and size. On import, Python compares those against the current source file; if either changed, the cache is stale and the module is recompiled. (Slide from David Beazley’s “Modules and Packages: Live and Let Die!”)

importlib.util exposes the pieces of this scheme directly: the version tag, the magic number that gates cache validity, and the function that maps a source path to its cached bytecode path.

(sys.implementation.cache_tag,
 importlib.util.MAGIC_NUMBER,
 importlib.util.cache_from_source("widget.py"))
('cpython-314', b'+\x0e\r\n', '__pycache__/widget.cpython-314.pyc')

The cache_tag (cpython-314) is embedded in the .pyc filename so that multiple Python versions can share a source tree without clobbering each other’s bytecode. Invalidation is normally based on the source’s modification time and size, but Python also supports hash-based .pyc files (comparing a hash of the source instead), which make builds reproducible when timestamps aren’t reliable.

6. Bending the Machinery

Because finders are just objects on a list, you can insert your own and change what import means. Two placements matter: prepend to sys.meta_path to intercept every import before the real finders run, or append to catch imports that would otherwise fail.

6.1 Tracing every import

A finder that prints and then returns None observes imports without handling them; returning None politely defers to the finders behind it:

class Watcher:
    "A meta-path finder that observes imports, then defers by returning None."
    @classmethod
    def find_spec(cls, name, path, target=None):
        print("find_spec ->", name)
        return None                    # None => let the finders behind us handle it

write_module("observed.py", "VALUE = 1")
sys.meta_path.insert(0, Watcher)       # prepend: we see every import first
try:
    import observed                    # a fresh module, so the finder actually fires
finally:
    sys.meta_path.remove(Watcher)
find_spec -> observed

The same idea one level up (wrapping builtins.__import__) intercepts the import statement itself:

_real_import = builtins.__import__

def traced_import(name, *args, **kwargs):
    if name not in sys.modules:        # only announce first-time imports
        print("importing:", name)
    return _real_import(name, *args, **kwargs)

builtins.__import__ = traced_import
try:
    import graphlib
finally:
    builtins.__import__ = _real_import
importing: graphlib

6.2 Auto-installing missing packages

Append a finder to the end of sys.meta_path and it becomes a last resort, consulted only after every real finder has failed. Here is the (in)famous auto-installer: on the first miss for a top-level name, it pip-installs the package and returns its now-findable spec.

import subprocess

class AutoInstall:
    "Last-resort finder: pip-install a missing top-level module on first miss."
    _tried = set()

    @classmethod
    def find_spec(cls, name, path, target=None):
        if path is not None or name in cls._tried:
            return None                # only top-level names, and only try once
        cls._tried.add(name)
        subprocess.check_call([sys.executable, "-m", "pip", "install", name])
        return importlib.util.find_spec(name)

sys.meta_path.append(AutoInstall)      # append => consulted only after real finders fail
WarningCute, not production

Auto-installing at import time is a great way to understand meta_path, and a terrible idea in real software: it runs the network and mutates the environment as a side effect of an import, with no version pinning and no reproducibility. Use it to learn; use a real dependency manager to ship.

6.3 Lazy imports

The create/execute split from §4.3 turns lazy loading into a few lines. The standard library ships importlib.util.LazyLoader, which wraps a loader so the module’s body doesn’t run until its first attribute access:

def lazy_import(name):
    spec = importlib.util.find_spec(name)
    if spec is None:
        raise ModuleNotFoundError(name)
    spec.loader = importlib.util.LazyLoader(spec.loader)
    module = importlib.util.module_from_spec(spec)
    sys.modules[name] = module
    spec.loader.exec_module(module)    # arms lazy loading; the body still hasn't run
    return module

write_module("expensive.py", """
    print(">>> expensive.py body is executing")
    ANSWER = 99
""")

mod = lazy_import("expensive")
print("bound lazily, nothing above printed yet")
mod.ANSWER                             # first attribute access triggers execution
bound lazily, nothing above printed yet
>>> expensive.py body is executing
99

Nothing printed when we “imported” expensive: the body ran only when we touched mod.ANSWER. Internally, LazyLoader uses exactly the trick you’d invent yourself: it returns a module whose __getattr__ triggers the real exec_module on first access, then swaps its own class back to a plain module so subsequent accesses are normal. This is how libraries with heavy optional dependencies keep import cheap.

7. Running Packages as Programs: __main__ and -m

The __main__ module is a special case. It is initialized directly at interpreter startup (like sys and builtins), and how it’s initialized depends on how you launched Python. Run a script and __main__ is that script; start a REPL and __main__ is the interactive session.

The -m flag is where this intersects the import system. python -m pkg.mod imports pkg (running its __init__.py), then runs pkg.mod as __main__, but with __package__ set correctly, so relative imports work. Run the same file as a bare path (python path/to/mod.py) and it has no package context, so relative imports fail. This mechanism is behind python -m pdb script.py, python -m http.server, and python -m venv.

A directory or zip file can itself be executable if it contains a __main__.py: python pkg/ (or python -m pkg) looks for pkg/__main__.py and runs it as the entry point, with the relevant __init__.py files still executing first.

Figure 2: Executable submodules. A __main__.py inside a subpackage turns it into a runnable entry point: python -m spam.test executes spam/test/__main__.py (after running spam/__init__.py and spam/test/__init__.py), while a subpackage without one, like spam.core, is import-only. (Slide from David Beazley’s “Modules and Packages: Live and Let Die!”)

Figure 2: Executable submodules. A __main__.py inside a subpackage turns it into a runnable entry point: python -m spam.test executes spam/test/__main__.py (after running spam/__init__.py and spam/test/__init__.py), while a subpackage without one, like spam.core, is import-only. (Slide from David Beazley’s “Modules and Packages: Live and Let Die!”)

Whether __main__.__spec__ is set reflects all of this: launched with -m (or as a directory or zip), __spec__ holds the corresponding module spec; launched as a plain script or with -c, it is None.

8. Gotchas and Failure Modes

8.1 reload creates zombies

importlib.reload does not give you a clean slate. It re-executes the module’s source into the same __dict__, so names from the previous version linger, and, worse, any class statement produces a brand-new class object while the old instances keep pointing at the old one. The two classes share a name but are not the same object:

write_module("widget.py", """
    print("widget.py body ran")
    class Widget:
        pass
""")
import widget
old = widget.Widget()

from importlib import reload
reload(widget)                         # re-runs the body into the SAME module dict
new = widget.Widget()

# Same class *name*, but a different class object -> old instances are "zombies":
old.__class__, new.__class__, type(old) is type(new), isinstance(old, widget.Widget)
widget.py body ran
widget.py body ran
(widget.Widget, widget.Widget, False, False)

Both objects report their class as widget.Widget, yet type(old) is type(new) is False and isinstance(old, widget.Widget) is False: old is now an instance of a class that no longer exists under that name. Reload also does not re-import already-loaded submodules (a reload-ed module that does import pandas keeps the old pandas). This is why reload is a REPL convenience, not a hot-swap mechanism: for anything stateful, restart the interpreter.

8.2 Prefer relative imports inside a package

Absolute imports (from mypackage.sub import thing) hard-code the package’s name into every import statement. Rename the package and every one of them breaks. Relative imports (from .sub import thing) refer to location, not name, and survive renames. Relative imports must use the from … import … form: import .sub is a syntax error, and a leading dot counts one level per dot (from ..pkg import x goes up two).

8.3 The cache is mutable

sys.modules is a plain dict, which is powerful and dangerous. Deleting a key forces a genuine reimport next time (a cleaner reset than reload). Aliasing a module under a second name (sys.modules["new"] = sys.modules["old"]) makes both names resolve to the same object. But setting an entry to None poisons that name: Python treats it as “known to be unimportable” and raises ModuleNotFoundError. Reach into the cache deliberately, not casually.

Conclusion

The import system rewards study out of all proportion to its size. Underneath import x is a four-step pipeline (find, create, execute, bind) dispatched through ordered lists of finders you can read and rewrite at runtime. Once you can see that pipeline, a whole class of problems stops being mysterious: shadowed modules, namespace packages that pick up the wrong directory, reloads that leave broken objects behind, plugin systems that must discover code dynamically.

Key Takeaways

  1. import is a cached pipeline, not a keyword that reads a file. Find → create → execute → bind, with sys.modules short-circuiting everything after the first import.
  2. A package is just a module with __path__. Regular packages (__init__.py) stop the search and win; namespace packages (no __init__.py) merge directories from across sys.path.
  3. sys.path is only the PathFinder’s input. The real dispatch layer is sys.meta_pathPathFindersys.path_hooks → per-entry finder → loader.
  4. The ModuleSpec is the hub. Finders produce specs; loaders consume them; create and execute are decoupled, which is what makes lazy loading possible.
  5. Source is compiled once and cached as version-tagged bytecode in __pycache__, validated against the source’s timestamp and size.
  6. Everything is pluggable. Prepend a finder to intercept imports, append one to catch failures: that’s all tracing, lazy imports, and auto-install really are.
  7. Know the traps. reload creates zombie objects, absolute imports are brittle to renames, and writing to sys.modules can corrupt the cache.
NoteThe machinery is stable

The spec/finder/loader design has been in place since Python 3.4 and hasn’t fundamentally changed since. Learn it once and it applies to every modern Python: the contents of sys.meta_path vary by environment, but the shape of the pipeline does not.

References & Resources

Back to top