skip to content

How does importlib.metadata.entry_points discover installed plugins without importing them?

level: middleimportance: should knowfreq 26%

answer

  1. Reads installed metadata, not code
  2. Grouped by a group name
  3. Names and targets, still no import
  4. load() is the importing step
  5. Only installed distributions are ever seen

basics

~20 s

It reads the metadata files that installed distributions leave on sys.path, so discovery is a file scan rather than an import. Each result carries a name and a module:attribute target string; calling EntryPoint.load() is the step that actually imports and resolves it.

solid answer

~40 s

`importlib.metadata.entry_points(group="myapp.parsers")` walks the distribution metadata directories found on `sys.path` and returns the entries declared under that group. Nothing is imported: each `importlib.metadata.EntryPoint` is just data — `.name`, `.group`, and `.value`, a `module:attribute` target string. `EntryPoint.load()` is the step that imports the module and resolves the attribute, which is why you can list every available plugin cheaply and pay the import cost only for the ones you use. Two operational consequences matter: only *installed* distributions are visible, so code merely on `PYTHONPATH` will not appear; and one broken plugin should never take down startup, so wrap each `.load()` in its own try/except and report the failure by name.

code

python · 6 lines
python
from importlib.metadata import entry_points

found = entry_points(group="console_scripts")
print(len(found), "declared; nothing imported yet")
for ep in sorted(found, key=lambda e: e.name)[:3]:
    print(ep.name, "->", ep.value)

go deeper

for a junior

Know that Python can ask the environment what plugins are installed, and that importlib.metadata.entry_points returns descriptions rather than imported objects. Loading is a separate, explicit step.

for a middle

Explain the split: discovery reads installer-written metadata on sys.path and yields name, group and a module:attribute target, while EntryPoint.load() does the import and getattr. Mention that only installed distributions are visible.

for a senior

Show how you operate a plugin host: cache the scan, load each entry defensively so one broken plugin cannot block startup, detect duplicate names, validate the loaded object's shape, and diagnose the 'importable but not discovered' report.

for a principal

Own the extension contract — which groups you publish, what stability promise the loaded object carries, how versions are negotiated, and the fact that the trust boundary is the environment itself rather than anything your loader can check.

### Discovery is a metadata scan, not an import When a distribution is installed, the installer writes a metadata directory alongside the code on `sys.path`, and any entry points the project declared land in a plain text file inside it, grouped by a group name. `importlib.metadata.entry_points()` reads those files. That is the whole trick, and it is what makes the mechanism worth using: you can enumerate every plugin available in the current environment without importing a single one of them, which means without executing any plugin code and without paying any import cost. Pass `group=` to select one group: `entry_points(group="myapp.parsers")` returns the entries declared under that group across every installed distribution. Each result is an `importlib.metadata.EntryPoint` carrying three things you care about — `.name`, the short logical name the plugin chose; `.group`; and `.value`, the target string in `module:attribute` form. Note that `.value` is exactly the dotted-string-plus-colon convention you would otherwise resolve by hand, which is not a coincidence: hand-rolled plugin config formats borrowed it from here. `EntryPoint.load()` performs the resolution — import the module named before the colon, `getattr` the attribute after it — and returns the object. This split is the design: **discovery is cheap and safe, loading is expensive and runs third-party code.** ### What this buys a plugin host The pattern replaces a hand-maintained registry. Your application never lists its plugins; it asks the environment what is installed. Installing a distribution adds a plugin, uninstalling removes it, and a virtual environment defines the plugin set. A `--list-parsers` flag becomes a metadata scan that prints names and target strings without importing anything, and the actual import happens only when a parser is selected. Contrast that with the two alternatives. A config file listing `module:attribute` strings gives you explicit control and works for code that was never installed as a distribution, but somebody has to maintain the list. Scanning a package directory for submodules and importing each one is worse on both counts: it imports everything, including the broken ones, and it only finds plugins that live inside your own package tree. ### The operational edges **Only installed distributions are visible.** A module sitting on `PYTHONPATH`, or a source tree you added to `sys.path` by hand, declares nothing and appears nowhere. Editable installs *are* installed, so they do show up; a plain `git clone` does not. This is the single most common confusion — "my plugin is importable but not discovered" almost always means it was never installed. **Names are not unique.** Two distributions may declare the same entry-point name in the same group. Nothing prevents it and nothing arbitrates it, so decide your own policy: reject duplicates loudly, or define a precedence rule and log what lost. **One bad plugin must not kill startup.** `EntryPoint.load()` imports third-party code and can raise anything at all — a missing transitive dependency, a syntax error under a newer Python, an exception in the module body. Wrap each load individually, keep going, and report the failed plugin's name and its distribution. A plugin host that dies on import of the least important plugin is a plugin host that cannot be operated. **Discovery has a cost, just a smaller one.** The scan walks `sys.path` and reads metadata directories. In a fat environment this is measurable at startup, so call it once and cache the result rather than per request. If you have added directories to `sys.path` during the run, the machinery may need `importlib.invalidate_caches()` before it sees them. **Verify what you loaded.** The target string is written by someone else. Check the loaded object is the shape you expect — callable, or a subclass of your plugin base — before storing it in a registry, so a mismatch fails at load with a clear message instead of at first use. ### Trust Discovery is safe; loading is not. Anything installed in the environment can declare an entry point in your group, and loading it runs its code. That is an acceptable model, because installing a distribution already grants code execution — but it does mean the security boundary is the environment, not your loader. If untrusted plugins are a real scenario, the answer is process or environment isolation, not a filter inside `load()`. ### Related lookups `importlib.metadata` covers the rest of the installed-distribution surface too: `importlib.metadata.version("somedist")` for a version string and `importlib.metadata.distributions()` to iterate every installed distribution. All of it reads the same metadata, so all of it stays on the cheap side of the import boundary.

  • A colleague says their plugin is importable but entry_points does not find it. What is the likely cause?
    It was never installed as a distribution. Discovery reads the metadata directories installers write next to the code on `sys.path`; a source tree added to `sys.path` by hand, or a plain checkout, declares no metadata and so appears nowhere. An editable install writes metadata and does show up, which is usually the fix.
  • Two installed distributions declare the same entry-point name in the same group. What happens?
    Nothing arbitrates it — both entries are returned, and any code that builds a dict keyed by name silently keeps whichever came last. Detect it explicitly: check for duplicate names while building your registry and either fail loudly or apply a documented precedence rule, logging the entry that lost and which distribution it came from.
  • Why wrap each EntryPoint.load() call separately rather than the whole loop?
    Because `load()` imports third-party code and can raise anything — a missing transitive dependency, an error in the module body. Wrapping the loop means the first failure ends discovery and the remaining plugins never load. Per-entry handling lets the host start with the plugins that work and report the ones that did not, by name and distribution.

saying these in an interview costs you the question

  • Thinks discovery imports every candidate plugin
  • Expects entry_points to find code merely on PYTHONPATH
  • Subscripts the entry_points() result like a dictionary
  • Lets one failing load abort the whole startup
  • Assumes entry-point names are unique across distributions
  • Confuses the discovery step with the load step

context