Skip to content
← All articles

PEP 810: explicit lazy imports

The 3.15 replacement for the function-local import hack: what `lazy import` binds, where it is a SyntaxError, and the five ways it can surprise you at runtime.

CLI startup time is a product metric. aws --version taking 1.8 seconds is a thing users notice, complain about, and switch tools over. The usual remedy has been the function-local import:

def render_table(rows):
    import rich.table          # 400ms saved on every other code path
    ...

It works, and it costs you everything static analysis was giving you. The import is invisible at the top of the file, so a reader cannot see the module’s dependencies. Linters cannot check it against the dependency graph. Import errors surface at the first call rather than at startup. And the same module gets imported in six different functions with no single place to change it.

Python 3.15 adds the language-level version:

lazy import json
lazy from pathlib import Path

What it actually does

lazy import json binds a types.LazyImportType proxy to the name json immediately, and performs the real import on the first use of the name. Not on first attribute access — on first use of the binding at all.

Because the statement is at module level, the reader, the linter and the type checker all see an ordinary import. Type checkers are specified to treat lazy imports exactly as normal imports, which makes this the modern replacement for if TYPE_CHECKING: blocks that exist for cost — not the ones that exist to break import cycles.

Controls, for when you want to change the policy without editing the source:

  • -X lazy_imports=all / =normal and the PYTHON_LAZY_IMPORTS environment variable;
  • sys.set_lazy_imports_filter() for programmatic control;
  • __lazy_modules__, the transitional mechanism that lets a package declare lazily-imported names in a way older interpreters can ignore.
💡lazy import is a SyntaxError inside functions, class bodies, and try/except. Which common pattern does the try/except restriction rule out, and what should you use instead? click to reveal

Optional-dependency probing.

try:
    import ujson as json_impl
except ImportError:
    import json as json_impl

This is the standard way to prefer a fast optional backend, and lazy cannot express it — by design. The whole point of lazy is that the import has not happened yet when the statement executes, so there is no ImportError to catch at that moment; it would arrive later, at the first use, from somewhere else entirely. Allowing the syntax would create a try block that catches nothing and an exception that surfaces outside it.

What to use instead: importlib.util.find_spec("ujson") is not None at module level to decide whether the module exists without importing it, then a plain (or lazy) import of the branch you chose. Or keep the eager try/except — an optional-dependency probe is usually cheap precisely because the module is small or absent.

The restriction to module scope is not an implementation limitation to be lifted later. It is what makes “the set of a module’s imports is visible at the top of the module” remain true.

The five ways it surprises you

1. Introspection does not reify. globals(), module.__dict__ and dir() report the proxy without triggering the load. Code that walks a module’s namespace looking for, say, every class with a given base will not find anything a lazy import would have provided.

2. sys.modules will not contain it. Any code doing sys.modules["yourlib.plugins"] — and there is more of that in the wild than you would like — sees a KeyError until something uses the name.

3. Import errors surface at first access. The traceback is chained back to the original lazy import line, which is the best the language can do, but the failure now happens inside whatever function first touched the name. A missing dependency that used to fail at startup can now fail in the middle of a request.

4. Import side effects never run. This is the big one. A module whose import registers something — a codec, a signal handler, a monkeypatch, a __init_subclass__-based plugin registry, a matplotlib backend — does its work at import time. Make that import lazy and the registration simply does not happen, and the failure appears as “the plugin is missing” a long way from the cause.

5. It does not fix circular imports in the general case. It can defer one side of a cycle far enough that the other side finishes initialising, but that is luck, not a guarantee, and it depends on which name is used first at runtime. Circular imports are a layering problem; the fix is to move the shared thing into a module both sides can depend on.

💡You make lazy import yourlib.plugins.postgres in a package whose plugin system relies on __init_subclass__ registering each backend. Everything passes CI. What breaks, and how would you have caught it? click to reveal

The backend is never registered, so get_backend("postgres") raises “unknown backend” at runtime — and only for the users who ask for postgres.

The mechanism: __init_subclass__ fires when the subclass’s class statement executes, which happens during module import. Nothing else in the codebase uses the name yourlib.plugins.postgres; it is imported purely for the side effect. A lazy import binds a proxy that is never touched, so the module body never runs, so the class statement never executes, so nothing registers.

CI passes because the tests that exercise the postgres backend almost certainly import yourlib.plugins.postgres themselves (directly or via a fixture), which reifies the proxy and makes the registration happen.

How to catch it: a test that asserts on the registry rather than on the behaviour — assert set(BACKENDS) == {"sqlite", "postgres", "mysql"} — run in a fresh interpreter with no other imports. That test fails immediately and points at the right line. It is the same shape of assertion as an import-linter contract: check the structure directly instead of hoping a behavioural test happens to traverse it.

The general rule: an import that exists for its side effect must not be lazy. If you cannot tell whether a module has import side effects, it does.

Where it fits

lazy import is a startup-cost tool. Reach for it when:

  • you have measured import time (python -X importtime -c "import yourpkg"),
  • the expensive module is used on a minority of code paths,
  • and the module has no import side effects.

Do not reach for it to break a cycle, to make an optional dependency optional, or as a blanket policy applied by a codemod. And note the version wall one more time: lazy import is syntax, so a module using it is a hard 3.15 dependency with no runtime fallback — requires-python = ">=3.15", or keep it in a module you import behind a version check.