Python 3.15 lets you mark an import as lazy, and for a command-line tool that pulls in heavy modules it can cut startup time to almost nothing. In our test, a small CLI that imports eight standard-library modules dropped from 72 ms to 26 ms to print its version, against 21 ms for an empty interpreter. The catch is that a lazy import moves two things to a later, less obvious moment: the module's side effects and its import errors. Both can bite if you add lazy everywhere without thinking.
How the lazy keyword works
The feature comes from PEP 810 and shipped in Python 3.15.0 on 9 October 2026. lazy is a soft keyword that goes in front of either import form:
lazy import json
lazy from pathlib import Path
According to What's New in Python 3.15, Python doesn't load the module at that line. It binds the name to a lightweight proxy, and the real import runs the first time the name is used. After that the proxy is replaced by the real object, so later accesses cost nothing extra. Because lazy is a soft keyword, lazy = "still a valid name" still runs, so existing code that uses it as a variable isn't broken.
We checked what the interpreter actually sees before and after first use:
import sys
lazy import json
print("json in sys.modules:", "json" in sys.modules)
print("globals()['json']:", type(globals()["json"]).__name__)
json.dumps({})
print("after first use, json in sys.modules:", "json" in sys.modules)
print("globals()['json'] now:", type(globals()["json"]).__name__)
json in sys.modules: False
globals()['json']: lazy_import
after first use, json in sys.modules: True
globals()['json'] now: module
That matches the PEP: a lazy module isn't in sys.modules until it's reified, and reading globals() or a module's __dict__ hands you the proxy rather than triggering the import. Code that walks module globals, such as some plugin loaders and serializers, will see these proxy objects. The docs expose the type as types.LazyImportType if you need to detect them.
Measuring the startup win
The win is largest when a program imports modules it often doesn't need. A CLI is the classic case: --help and --version shouldn't pay for your HTTP client and database driver. Our test script imports argparse eagerly and seven other stdlib modules either eagerly or lazily:
import argparse
lazy import asyncio
lazy import email.mime.multipart
lazy import http.client
lazy import json
lazy import sqlite3
lazy import decimal
lazy import xml.etree.ElementTree
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--version", action="store_true")
args = parser.parse_args()
if args.version:
print("tool 1.0")
return
print(json.dumps({"ok": True}))
main()
The eager version is identical without the lazy keywords. We ran each variant 40 times as a fresh process with Python 3.15.0 (Homebrew, macOS) and took the median wall time:
bare interpreter 20.8 ms
eager --version 72.4 ms
lazy --version 26.4 ms
eager (uses json) 72.4 ms
lazy (uses json) 27.6 ms
eager -X lazy_imports=all --version 25.9 ms
Two things stand out. Using json barely moved the lazy number, because the cost was in asyncio, email and friends, which never got touched. And the last line is the eager file, unchanged, run with -X lazy_imports=all. That global mode makes every module-level import "potentially lazy", so you can measure the possible gain for an existing project before editing any source. The numbers will differ on your hardware, but the shape won't: lazy imports save exactly the import time of modules you don't end up using.
The standard library uses the keyword as well. In the 3.15.0 sources, site.py lazily imports locale, pkgutil, traceback and warnings, and modules like dataclasses, pathlib and inspect defer some of their own dependencies.
Import errors move to the first use
A missing module no longer fails at the import line. It fails wherever the name is first used, which might be deep inside a request handler hours after deployment. These lines come from a longer probe script, so the traceback's line numbers are from that file (paths shortened):
lazy import does_not_exist
print("still alive; touching does_not_exist...")
does_not_exist.run()
still alive; touching does_not_exist...
Traceback (most recent call last):
File "probe.py", line 3, in <module>
lazy import does_not_exist
ImportError: lazy import of 'does_not_exist' raised an exception during resolution
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "probe.py", line 21, in <module>
does_not_exist.run()
^^^^^^^^^^^^^^
ModuleNotFoundError: No module named 'does_not_exist'
The chained traceback is genuinely helpful: it points at both the import statement and the access site. The problem is timing. A packaging mistake that used to crash the process at startup now surfaces on whatever code path first touches the module, so make sure your tests actually exercise those paths. A smoke test that just starts the app proves less than it did.
You also can't use the old optional-dependency idiom. lazy is only allowed at module scope and never inside try/except, and the compiler says so:
try:
lazy import ujson as json
except ImportError:
import json
File "fallback.py", line 2
lazy import ujson as json
^^^^^^^^^^^^^^^^^^^^^^^^^
SyntaxError: lazy import not allowed inside try/except blocks
That restriction makes sense: an import that hasn't run yet can't raise ImportError inside the try. Keep fallback imports eager.
Side-effect imports silently stop working
This is the trap most likely to slip through review. Plenty of code imports a module only so that its top-level code runs, typically a decorator that registers a handler:
# registry.py
HANDLERS = {}
def register(name):
def deco(fn):
HANDLERS[name] = fn
return fn
return deco
# csv_handler.py
from registry import register
@register("csv")
def export_csv(rows):
return "\n".join(",".join(r) for r in rows)
# app.py
from registry import HANDLERS
lazy import csv_handler # imported only for its @register side effect
print("handlers:", sorted(HANDLERS))
Nothing ever reads the name csv_handler, so the module never loads and the registry stays empty. There's no error:
handlers: []
The PEP itself warns about registries that rely on import side effects and recommends explicit discovery instead. With the plain import csv_handler the output is handlers: ['csv']. Run that same plain-import file under -X lazy_imports=all, though, and it prints handlers: [] again, which is why the global mode is a measuring tool before it's a deployment setting.
If you do want all mode in an application, the sys.set_lazy_imports_filter callback lets you exempt modules. It receives the importing module, the imported module and the fromlist, and returns False to force an eager import:
import sys
EAGER = {"csv_handler"} # modules imported for their side effects
def keep_side_effects(importing, imported, fromlist):
return imported not in EAGER
sys.set_lazy_imports_filter(keep_side_effects)
sys.set_lazy_imports("all")
from registry import HANDLERS
import csv_handler
print("handlers:", sorted(HANDLERS))
handlers: ['csv']
The docs mark both functions as tools for application owners and say library authors should generally leave the global mode alone.
There is no "none" mode
PEP 810 describes a third mode, none, that would disable even explicit lazy imports. It didn't make it into 3.15.0. The command-line documentation lists only all and normal for -X lazy_imports and PYTHON_LAZY_IMPORTS, and the interpreter refuses anything else:
Fatal Python error: config_init_lazy_imports: -X lazy_imports: invalid value; expected 'all' or 'normal'
sys.set_lazy_imports("none") raises ValueError: mode must be 'normal' or 'all'. If you need everything eager, for example to debug an ordering problem, a filter that always returns False does it. With sys.set_lazy_imports_filter(lambda importing, imported, fromlist: False) in place, a following lazy import decimal loaded the module immediately in our test.
Supporting Python 3.14 and older
The lazy keyword is a syntax error on older interpreters, so a library that supports 3.14 can't use it yet. For that case the module can declare __lazy_modules__, a list of module names whose ordinary import statements 3.15 treats as lazy:
import sys
__lazy_modules__ = ["decimal"]
import decimal
print(sys.version.split()[0], "decimal loaded:", "decimal" in sys.modules)
print(decimal.Decimal("0.1") + decimal.Decimal("0.2"))
print(sys.version.split()[0], "decimal loaded:", "decimal" in sys.modules)
3.15.0 decimal loaded: False
0.3
3.15.0 decimal loaded: True
Per the PEP, older versions ignore the declaration and import eagerly as before. Put the declaration above the imports it covers: when we moved it below import decimal, the module loaded eagerly. For new code that only targets 3.15, our advice is to make CLI entry points and rarely used heavy dependencies lazy, keep side-effect and fallback imports eager, and not to reach for -X lazy_imports=all in production until a test run under it comes back clean.
No comment for this article.