Contents
Python 3.15 adds a lazy soft keyword for imports (PEP 810). The headline is startup time. The consequence that will bite a production service is different: both import-time failures and import-time side effects move to the first access of the name. A handler registered by a decorator in a lazily imported module is not registered until something touches the module, and nothing tells you.
I tested this on 3.15.0b4 (the only 3.15 build I installed; the docs I read are for rc3). The final release is scheduled for 2026-10-09, so treat anything below as behavior to re-check against the final build.
What the keyword does
lazy import x binds x to a proxy and loads the module the first time the name is used. lazy from x import y works the same way. Per the 3.15 What’s New, lazy imports are legal only at module scope, are a SyntaxError inside try/except/finally, and cannot be star imports. A global switch makes ordinary imports lazy too: -X lazy_imports=all or PYTHON_LAZY_IMPORTS=all, with normal as the default. A module can also opt in without new syntax by listing names in __lazy_modules__.
The PEP claims startup reductions of 50 to 70 percent and memory savings of 30 to 40 percent “in practice”, and a benchmark table showing no measurable overhead after reification. Those are the PEP authors’ numbers. I did not benchmark startup, so I am not repeating them as a result.
Experiment 1: a missing dependency fails late
lazy import not_installed_pkg
print("boot ok")
def view():
return not_installed_pkg.go()
print("calling view")
view()
Output: boot ok, calling view, then a chained traceback ending in ModuleNotFoundError: No module named 'not_installed_pkg', with the first line reading ImportError: lazy import of 'not_installed_pkg' raised an exception during resolution. The traceback points at the access inside view(). That matches the PEP’s description and is a decent traceback.
The operational difference is where it happens. A bad deploy with a missing wheel used to crash the process at boot, which your orchestrator catches in the first health check. Now the process boots, passes the health check, and fails on the first request that reaches that code path.
Experiment 2: a registry that stays empty
This is the one I would audit for first. A minimal version of a pattern every Django, Celery and webhook codebase has:
# registry.py
HANDLERS = {}
def register(name):
def deco(fn):
HANDLERS[name] = fn
return fn
return deco
# plugins/stripe_handler.py
from registry import register
print("stripe_handler imported")
@register("stripe")
def handle(evt): return "stripe ok"
With a normal import plugins.stripe_handler, the script prints stripe_handler imported and HANDLERS contains stripe. With lazy import plugins.stripe_handler, HANDLERS is empty and the print never happens. Running the original eager version with -X lazy_imports=all gives the same empty registry. No error, no warning.
The PEP says this directly: side effects are deferred until first use, and modules that “rely on import-time registration patterns” may need changes. What it means in practice is that the failure shows up later as a KeyError: 'stripe' when the first webhook arrives, far from the import that caused it.
If you use the lazy keyword by hand, this stays visible in the diff. The global switch is the risky one, because it changes the meaning of imports nobody edited.
What held up
Plain try/except ImportError stays eager. I ran the common optional-dependency fallback with -X lazy_imports=all and it still took the fallback path. With __lazy_modules__ listing a missing module, a plain import inside a try block raised inside the try and was caught there; the name was left unbound afterwards. So the orjson-or-json pattern does not silently turn lazy. Writing lazy import inside the try is a syntax error (lazy import not allowed inside try/except blocks).
The filter keeps registration modules eager. sys.set_lazy_imports_filter() takes a callable (importing, imported, fromlist) that returns True to allow laziness. This worked as documented:
import sys
def keep_plugins_eager(importing, imported, fromlist):
return not imported.startswith("plugins")
sys.set_lazy_imports_filter(keep_plugins_eager)
sys.set_lazy_imports("all")
import registry
import plugins.stripe_handler # imported eagerly, HANDLERS has "stripe"
Per the PEP the filter runs when the import statement executes, not at reification, and may be called concurrently, so keep it free of shared mutable state.
First attribute access triggers the load. Using sh.handle on lazy import plugins.stripe_handler as sh ran the module and populated the registry. globals() and __dict__ do not reify, per the PEP, so introspection tools that walk module dicts will see proxy objects of type types.LazyImportType.
What did not: the resolve-everything boot check
The obvious guard is a function at startup that walks module globals and calls .resolve() on every lazy proxy, turning late failures back into boot failures. For the missing package case, it works: the process dies at boot with ModuleNotFoundError.
For the registry case it does not. With lazy import plugins.stripe_handler, the binding in globals() is the top-level name plugins. Calling .resolve() on it imported the package, and HANDLERS was still empty. A guard that only resolves bindings in globals() can pass while a dotted submodule that matters is never imported. If a module exists for its side effects, the reliable options I found are to import it eagerly, exclude it with the filter, or touch a name from the submodule you actually need.
What I would check before enabling it
- Search for decorator registries, signal receivers, model and admin registration, Celery task modules and plugin entry points. Any module imported only for its side effects should stay eager.
- Leave
lazy_importsatnormalfor services you do not fully control, and apply thelazykeyword to specific heavy imports in specific modules. The globalallmode changes code that nobody reviewed. - Add a smoke test that exercises each externally triggered code path once in CI, since a missing dependency no longer fails the import step.
- Do not assume lazy imports fix circular imports. The PEP states they help only if the circular reference is not touched during module initialization.
I have not tested Django’s app loading, Celery autodiscovery or SQLAlchemy mapper registration under lazy mode, so those are open questions rather than findings. The experiments above used a single-file registry only.
Sources
- PEP 810, Explicit lazy imports
- What’s new in Python 3.15 (rc3 documentation)
- Python 3.15.0rc3 release page, for the scheduled final release date
- All behavior reports under “Experiment” and “What held up” come from my own scripts run on
3.15.0b4(Linux x86-64). Performance figures are the PEP’s claims and were not measured by me.