skip to content

Meta Path Finders

The hooks on sys.meta_path that decide where a module comes from, and the spec a finder hands to a loader. Interviewers ask when imports arrive from a zip file, a plugin store, or a lazy shim.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

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

open as a page

What does an importlib.machinery.ModuleSpec carry, and how does a loader use it?

level: middleimportance: should knowfreq 22%

basics

~20 s

A ModuleSpec carries the module's name, the loader that can execute it, its origin, and for a package its submodule_search_locations. The machinery builds a module object from the spec, registers it under its name, then calls loader.exec_module(module) to run the body.

open as a page

How does importlib.util.LazyLoader defer a module's execution, and when does that backfire?

level: seniorimportance: should knowfreq 20%

basics

~20 s

LazyLoader wraps a real loader. The module object it produces is a placeholder whose class is swapped, so the wrapped loader runs the body on the first attribute access instead of at import. Import cost moves to first use, and so do import-time errors and side effects.

open as a page

How does Python import modules straight out of a .zip file on sys.path?

level: seniorimportance: nice to knowfreq 14%

basics

~20 s

A .zip entry on sys.path is handled by sys.path_hooks: the zipimport hook accepts the entry and returns a zipimport.zipimporter for it, cached in sys.path_importer_cache. That finder reads .py and .pyc members out of the archive, so no file is ever unpacked to disk.

open as a page