Python 3.15 Lazy Imports Move Your ImportError. Audit What Else Moves With It.
The feature that delayed the final release by a week changes when failures surface, not just how fast programs start.
Python 3.15 was due to ship earlier than it will. A third release candidate appeared on 2 October because of last-minute lazy-import blockers, and the final release is now scheduled for 9 October, a week later than planned, according to the Python release announcement. That detail is the best summary of the feature. Python 3.15 lazy imports look like a speed feature, and the interpreter team treated them as a semantics change.
Most coverage so far repeats the startup numbers. This piece covers the other half: what changes about when your program fails, and which parts of a codebase should not adopt the keyword at all. It is written for a backend or tooling engineer who owns a Python service or CLI and is deciding what to do on upgrade day.
What the lazy keyword actually does
Under PEP 810, lazy is a soft keyword placed before an import at module level. The statement runs, but instead of loading the module it binds the name to a proxy object. The module loads the first time the name is used, and from then on the binding behaves like an ordinary eager import.
lazy import json
lazy from pathlib import Path
print("starting") # neither module is loaded yet
data = json.loads("{}") # json loads here
The from form is finer grained. With lazy from json import dumps, loads, each imported name gets its own proxy. Touching one loads the whole module, but only that name is resolved. The other proxy stays in place until it is used.
There are three switches around the keyword. A module can list names in __lazy_modules__ to mark plain imports as lazy on 3.15 and newer. The -X lazy_imports flag, or the PYTHON_LAZY_IMPORTS environment variable, takes normal, all or none. And sys.set_lazy_imports_filter() lets you veto laziness for specific modules by returning False for them.
Three things that move to first use
A lazy import defers more than the file read. Everything an import does moves with it. The PEP is explicit about this, and it is the part that gets lost in the startup-time summaries.
| Behaviour | Eager import | Lazy import |
|---|---|---|
| Missing module or attribute | ImportError at the import line, at process start | ImportError at first use, far from the import line |
| Import-time side effects | Run once, at start | Run at first use, or never if the name is never touched |
| Presence in sys.modules | Present after the import statement | Absent until the name is resolved |
| Contents of globals() | Real module objects | Proxy objects until resolved |
The first row is the one that changes operations. A typo in a dependency name used to kill the process during startup, where a deploy health check would catch it. With a lazy import the process starts cleanly and fails the first time a request reaches that code path. The traceback does show both the import site and the access site, which helps, but the failure has already reached a user.
The second and third rows break code that assumes imports populate state. Anything that checks sys.modules to see whether a library is loaded will get a different answer. Anything that iterates globals() and expects modules will meet proxies.
Where Python 3.15 lazy imports will bite
Search your codebase for these patterns before adding the keyword anywhere, and before turning on the global all mode.
- Registries filled by decorators or class definitions at import time, such as plugin systems, command tables and serializer registries. If nothing touches the name, the registration never happens.
- Modules that monkey-patch another library when imported. The patch lands at first use, which may be after the code you wanted patched has already run.
- Optional dependency fallbacks written as try/except ImportError. The keyword is rejected inside try blocks, and in all mode such imports are left eager, so these keep working but get none of the speedup.
- Introspection helpers that read globals() or sys.modules. They see proxies or absences instead of modules.
Circular imports are a separate trap. The PEP is clear that laziness does not cure them. It only helps when the cycle is deferred until after both modules finish initialising.
A check that forces every lazy import to resolve
The cheapest protection is a test that resolves every lazy name in your package at once, so a missing dependency fails in CI and not in production. The PEP describes a resolve() method on the proxy and says that reading globals() does not trigger loading, which is enough to write the sketch below.
import importlib
import types
MODULES = ["myapp.cli", "myapp.exporters", "myapp.plugins"]
def test_all_lazy_imports_resolve():
for name in MODULES:
module = importlib.import_module(name)
for attr, value in list(vars(module).items()):
if isinstance(value, types.LazyImportType):
value.resolve() # raises ImportError now, not in production
A second pass catches side-effect dependence. Run the existing test suite once with PYTHON_LAZY_IMPORTS=all. Any test that fails there is relying on an import having happened as a side effect, which is the exact class of bug the keyword introduces.
Servers and CLIs want different defaults
“A long-lived server pays the import cost once. Deferring it only changes where the failure shows up.”
The PEP cites deployments at Meta and Hudson River Trading reporting 50 to 70 percent lower startup time for command-line tools and 30 to 40 percent lower memory in large applications. Those numbers come from the PEP itself, and they depend heavily on how deep the dependency graph is. A tool whose --help imports a machine-learning framework it never uses gains the most. The release notes also describe a separate JIT upgrade, but that is a different feature with a different adoption path.
The split follows from what each process wants. A command-line tool or a short-lived function starts often and exits quickly, so startup dominates and a late ImportError affects one run. A long-lived service starts rarely and serves for days. For that service a boot-time failure is a feature: the orchestrator sees the crash, the rollout halts, and no user is affected. Moving the failure into a request path removes that safety net in exchange for saving a few hundred milliseconds once.
A reasonable default is to adopt the keyword at CLI entry points and cold-start-sensitive code, keep services eager, and use the filter function to exclude any module with import-time side effects.
Measure before you rewrite imports
python -X importtime prints a per-module breakdown of import cost to stderr and has done so since Python 3.7. Run it on your entry point first. If three modules account for most of the time, make those three lazy and leave the rest alone. A scattershot rewrite buys the full semantic risk for a small fraction of the gain.
Python 3.15 also brings frozendict, a sentinel builtin, UTF-8 as the default encoding and removals such as datetime.utcnow(), each of which deserves its own upgrade pass. The lazy keyword is the one whose risk is invisible in a diff, because the line looks nearly identical to the one it replaces. The first projects to publish their own lazy-import audits after the final release will set the conventions everyone else copies.
Frequently asked questions
Related reading
Postgres 19 Upgrade Checklist: Five Defaults That Change Without Touching Your Queries
Postgres 19 is close to release. The risky parts are not the headline features but the changed defaults and removed options. Here is what to check on your cluster first.
AI Pull Requests Wait 5x Longer for Review. Queueing Math Explains Why, and It Gets Worse.
LinearB data on 8.1 million pull requests shows agent PRs wait 17.6 hours for a first review, but get reviewed faster once picked up. That is a queue, and queues behave nonlinearly.
A Server Response Crashed Thousands of iOS Apps at Launch. Your SDK Is a Remote Control.
A malformed server payload crashed thousands of iPhone apps through the Firebase SDK. No app shipped a bug. Here is how to stop a vendor backend from deciding whether your app opens.