skip to content

How does sys.meta_path drive what happens when Python runs an import statement?

level: middleimportance: must knowfreq 40%

answer

  1. Import is a protocol, not a lookup
  2. A list of hooks, walked in order
  3. Finding is separate from loading
  4. Each entry returns a spec or abstains
  5. Builtin, frozen, then the path finder

basics

~20 s

Python asks each object in sys.meta_path, in order, to find the module by calling find_spec(name, path, target). The first finder that returns an importlib.machinery.ModuleSpec wins, and that spec names the loader which will actually execute the module.

solid answer

~40 s

`sys.meta_path` is a plain list of *finder* objects, and the import machinery walks it in order for every import it has to satisfy. Each entry is called as `find_spec(fullname, path, target=None)` and answers either with an `importlib.machinery.ModuleSpec` or with `None`, which means *abstain, ask the next one*; if every finder abstains the import raises `ModuleNotFoundError`. On CPython 3.14 the three defaults are `BuiltinImporter`, `FrozenImporter` and `PathFinder`, in that order, and only the last consults `sys.path` — it turns each path entry into a path entry finder through `sys.path_hooks`. Because finding is deliberately separate from loading, inserting your own finder at index 0 lets a module arrive from an archive, an object store or generated source without editing a single import statement.

code

python · 25 lines
python
import sys
from importlib.machinery import ModuleSpec


class GreetingLoader:
    def create_module(self, spec):
        return None

    def exec_module(self, module):
        module.greet = lambda who: f"hello {who}"


class GreetingFinder:
    def find_spec(self, fullname, path, target=None):
        if fullname != "greeting":
            return None
        return ModuleSpec(fullname, GreetingLoader())


sys.meta_path.insert(0, GreetingFinder())

import greeting

print(greeting.greet("world"))
print(type(greeting.__spec__).__name__)

go deeper

for a junior

You are not expected to write a finder. Be able to say that an import is machinery you can hook, not a hard-coded file read, and that sys.meta_path is the list where those hooks live.

for a middle

Be ready to name the call, find_spec(fullname, path, target), say what returning None means, and name the three defaults. Explaining why finding and loading are separate phases is the core of the answer.

for a senior

Show the operational side: what a finder costs on every import in the process, why it must be thread-safe, why it needs invalidate_caches() when its source can change, and when a plain import shim is the cheaper answer.

for a principal

Own the decision. A custom finder is invisible machinery that every future reader must discover, so argue for it only when the alternative is worse, and set the boundary between import-time magic and explicit factory code for the codebase.

## Import is a protocol, not a filesystem lookup An `import spam` statement compiles to a call into the import machinery, which is implemented in Python in `importlib._bootstrap` and frozen into the interpreter. Once the machinery decides it actually has to import the name, it runs two clearly separated phases: **finding** — deciding where the module comes from and who can execute it — and **loading** — creating the module object and running its body. `sys.meta_path` is the extension point for the first phase. ## The walk `sys.meta_path` is an ordinary mutable list. For each import the machinery iterates it front to back and calls, on every entry: ```python finder.find_spec(fullname, path, target=None) ``` * `fullname` is the fully qualified dotted name, for example `pkg.sub`. * `path` is `None` for a top level import. For a submodule, the parent package is imported first and its `__path__` is passed here, so a finder searches inside the package rather than at the top level. * `target` is an existing module object, supplied only during a reload, so a finder can take the previous state into account. A finder returns either an `importlib.machinery.ModuleSpec` describing the module, or `None`. Returning `None` is how a finder says *this name is not mine* — it must **not** raise `ModuleNotFoundError` to decline, because that would abort the whole search instead of letting later finders answer. When every finder returns `None`, the machinery itself raises `ModuleNotFoundError`. ## The three defaults On CPython 3.14 an untouched interpreter starts with exactly three entries: 1. `importlib.machinery.BuiltinImporter` — modules compiled into the interpreter binary, such as `sys` and `_thread`. This is why there is no `sys.py` anywhere on disk. 2. `importlib.machinery.FrozenImporter` — modules whose bytecode is frozen into the binary, including the import machinery itself and much of the startup path. 3. `importlib.machinery.PathFinder` — everything else, and the only one of the three that knows about `sys.path`. Order is meaningful: a finder inserted at index 0 sees every import in the process before the standard ones do, while a finder appended to the end acts as a last-resort fallback for names nothing else could resolve. ## Two extension points, not one `PathFinder` is itself just another meta path finder. To search a single `sys.path` entry it needs a **path entry finder**, which it obtains by calling each callable in `sys.path_hooks` with the entry string until one accepts it; a hook that cannot handle an entry raises `ImportError` and the next is tried. The result is memoised in `sys.path_importer_cache`, keyed by the path entry. The two standard hooks yield a `zipimport.zipimporter` for an archive and a `FileFinder` for a directory. So the machinery gives you two distinct levers. `sys.meta_path` answers *what kinds of sources can modules come from at all*, and it bypasses `sys.path` entirely. `sys.path_hooks` answers *what kinds of things may appear as a path entry*, and it only matters for names that reach `PathFinder`. ## Writing one A finder is duck-typed: any object with `find_spec` works, though inheriting `importlib.abc.MetaPathFinder` documents the intent and supplies a default `invalidate_caches()`. Real uses are narrower than they look — remapping renamed packages during a migration, importing generated code produced at first use, serving modules out of an archive or a content store, instrumenting or auditing every import, or refusing imports outside an allow-list in a restricted environment. The costs are worth stating in an interview. Every import in the process pays your finder's cost, including the failing lookups, so a finder that does network or database I/O before abstaining will visibly slow startup. Your `find_spec` can be re-entered from several threads, so it must be thread-safe on its own; the per-module import lock does not protect it. And if your source can change while the process runs, you must implement `invalidate_caches()` so `importlib.invalidate_caches()` reaches you. ## Version notes The legacy protocol built on `find_module` and `load_module` was deprecated for years and **removed in Python 3.12**, so on 3.14 a meta path finder that only defines the old methods is simply ignored. The set of default entries is unchanged in 3.14. To see the machinery working, `python -X importtime` prints the cumulative cost of every import, and any imported module exposes the spec it was built from as its `__spec__`.

  • What do the three default entries on sys.meta_path each handle?
    `BuiltinImporter` resolves modules compiled into the interpreter binary, such as `sys`. `FrozenImporter` resolves modules whose bytecode is frozen into the binary, including the import machinery and most of the startup path. `PathFinder` handles everything else and is the only one that consults `sys.path`, delegating each entry to a path entry finder built through `sys.path_hooks`.
  • Why must find_spec return None rather than raise when it cannot handle a name?
    `None` means *abstain*, and the machinery moves on to the next finder. Raising `ModuleNotFoundError` aborts the whole search, so a finder that raises when it simply does not recognise a name breaks every import the later finders would have resolved. The machinery raises `ModuleNotFoundError` itself once every entry has abstained.
  • What is the difference between putting a hook in sys.meta_path and in sys.path_hooks?
    A `sys.meta_path` finder is consulted for every import and can resolve names that appear nowhere on `sys.path`. A `sys.path_hooks` callable only extends what may appear *as a path entry*: `PathFinder` calls it with each `sys.path` string and caches the resulting path entry finder in `sys.path_importer_cache`. Archives are supported through the second mechanism.

saying these in an interview costs you the question

  • Thinks import always reads a .py file from disk
  • Confuses a finder with a loader; one locates, one executes
  • Says find_spec should raise ImportError to decline a name
  • Believes sys.meta_path is only consulted for third-party code
  • Cannot say where a custom finder is registered
  • Assumes appending a finder makes it take precedence

context