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/=normaland thePYTHON_LAZY_IMPORTSenvironment 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.