Lazy Imports (PEP 810)
Lazy Imports (PEP 810)
Thesis
Python 3.15 adds a soft keyword, lazy, that turns an import at module scope into a promise: the name is bound now, the module is found, loaded, and executed the first time the name is used. That fixes the oldest startup tax in the language — a CLI that loads its whole dependency graph to print --help — without the old workarounds (imports buried in functions, importlib tricks, if TYPE_CHECKING: blocks). The price is that import errors and import side effects move from the top of the file to wherever the name is first touched. Use lazy deliberately on heavy, side-effect-free dependencies of short-lived programs; keep everything else eager; and make your tests reify what you deferred.
Version note: lazy imports are the headline feature of Python 3.15 (final release scheduled for 2026-10-09 after a late rc3 for lazy-import fixes). Every listing below was run on CPython 3.15.0rc2 (Linux x86-64) with uv; output is copied from those runs. Where 3.15 behaves differently from the PEP text, this chapter follows the 3.15 documentation.
Mental model
lazy import json # 1. bind json -> <lazy_import 'json'> (nothing found, nothing executed)
...
json.dumps(x) # 2. first use: LOAD_GLOBAL/LOAD_NAME sees the proxy
# -> runs the normal import machinery NOW
# -> sys.modules['json'] = module; rebinds the global
json.dumps(y) # 3. ordinary module access; the interpreter specializes it
| Question | Eager import x |
lazy import x |
|---|---|---|
When is x located and executed? |
At the statement | At first use of the name |
Where does ModuleNotFoundError surface? |
The import line | The first-use line (chained to the import line) |
| When do import-time side effects run? | At startup | At first use — possibly never |
In sys.modules before use? |
Yes | No (listed in sys.lazy_modules) |
| After first use | Module object | Indistinguishable module object |
Rules that the compiler enforces:
lazyis legal only at module scope — not in a function, a class body, or atry/except/finallyblock (each is aSyntaxError).lazy from m import *andlazy from __future__ import …are errors.lazyis a soft keyword:lazy = 3still works everywhere else.lazy from m import a, bbinds two proxies; the first one touched loads all ofmand resolves only that name.- Laziness is local and non-recursive:
lazy import pkgdoes not makepkg’s own imports lazy.
Two compatibility and control knobs:
__lazy_modules__— a module-level container of fully qualified module names. On 3.15+ a plainimportof a listed module behaves as if it hadlazy; on 3.14 and older the variable is just an unused global.- Global mode —
-X lazy_imports=all,PYTHON_LAZY_IMPORTS=all, orsys.set_lazy_imports("all")make every eligible module-level import potentially lazy;"normal"(the default) honours onlylazyand__lazy_modules__. A filter,sys.set_lazy_imports_filter(fn), gets(importer, name, fromlist)and returnsFalseto force an import eager. (PEP 810 also described a"none"mode; CPython 3.15 accepts only"normal"and"all"—"none"is a fatal startup error on 3.15.0rc2.)
Worked examples
Case 1: A multi-command CLI that only pays for the command you run
Save as deploy.py. Each subcommand needs a different, non-trivial part of the standard library. With lazy, deploy status pays only for json.
# deploy.py — a CLI whose subcommands need very different libraries
import argparse
import sys
lazy import json
lazy import sqlite3
lazy import tarfile
lazy import urllib.request
lazy from email.message import EmailMessage
def cmd_status(args):
print(json.dumps({"service": args.service, "state": "running"}))
def cmd_backup(args):
with sqlite3.connect(":memory:") as db, tarfile.open(args.out, "w:gz"):
db.execute("select 1")
print("backup written to", args.out)
def cmd_notify(args):
msg = EmailMessage()
msg["Subject"] = f"{args.service} deployed"
print(msg["Subject"])
def main(argv=None):
p = argparse.ArgumentParser(prog="deploy")
sub = p.add_subparsers(required=True)
s = sub.add_parser("status"); s.add_argument("service"); s.set_defaults(fn=cmd_status)
b = sub.add_parser("backup"); b.add_argument("out"); b.set_defaults(fn=cmd_backup)
n = sub.add_parser("notify"); n.add_argument("service"); n.set_defaults(fn=cmd_notify)
args = p.parse_args(argv)
args.fn(args)
heavy = ["json", "sqlite3", "tarfile", "urllib.request", "email.message"]
print("loaded:", [m for m in heavy if m in sys.modules], file=sys.stderr)
print("modules:", len(sys.modules), file=sys.stderr)
if __name__ == "__main__":
main()Make an eager twin for comparison (same file without the keyword) and run both:
sed 's/^lazy //' deploy.py > deploy_eager.py
uv run --python 3.15 python deploy.py status web
uv run --python 3.15 python deploy_eager.py status web
uv run --python 3.15 python deploy.py notify webOutput:
{"service": "web", "state": "running"}
loaded: ['json']
modules: 75
{"service": "web", "state": "running"}
loaded: ['json', 'sqlite3', 'tarfile', 'urllib.request', 'email.message']
modules: 136
web deployed
loaded: ['email.message']
modules: 104
Same code, same imports listed at the top where a reader expects them, 61 fewer modules executed for status. To see where time goes, ask the interpreter:
uv run --python 3.15 python -X importtime deploy_eager.py status web 2>&1 \
| grep -E ' (sqlite3|tarfile|email\.message|urllib\.request)$'Output (microseconds: self | cumulative; your numbers will differ):
import time: 180 | 1512 | sqlite3
import time: 1422 | 6160 | tarfile
import time: 697 | 1090 | email.message
import time: 1272 | 20131 | urllib.request
Wall clock, measured with a small harness:
# bench.py — median wall time of N fresh interpreter starts
import statistics
import subprocess
import sys
import time
def median_ms(cmd, n=30):
samples = []
for _ in range(n):
t0 = time.perf_counter()
subprocess.run(cmd, check=True, capture_output=True)
samples.append(time.perf_counter() - t0)
return statistics.median(samples) * 1000
for script in ("deploy_eager.py", "deploy.py"):
print(f"{script:16} {median_ms([sys.executable, script, 'status', 'web']):6.1f} ms")uv run --python 3.15 python bench.pyOutput on the author’s box (illustrative — timings depend on CPU, disk cache, and build):
deploy_eager.py 54.0 ms
deploy.py 28.3 ms
Roughly half the startup for the common path, for the price of one keyword per line.
Case 2: The error moves — and so must your smoke test
Save as report.py. The yaml dependency is missing from this environment, and nothing complains until the one code path that uses it runs.
# report.py — the dependency is missing, but nobody notices at startup
import sys
lazy import yaml # not installed in this environment
print("started; lazy modules pending:", sorted(m for m in sys.lazy_modules if m == "yaml"))
def render(data):
return yaml.safe_dump(data)
if len(sys.argv) > 1 and sys.argv[1] == "--yaml":
print(render({"ok": True}))
else:
print("plain output: ok")uv run --python 3.15 python report.py; echo "exit=$?"
uv run --python 3.15 python report.py --yaml; echo "exit=$?"Output (paths shortened):
started; lazy modules pending: ['yaml']
plain output: ok
exit=0
started; lazy modules pending: ['yaml']
Traceback (most recent call last):
File "report.py", line 4, in <module>
lazy import yaml # not installed in this environment
ImportError: lazy import of 'yaml' raised an exception during resolution
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "report.py", line 14, in <module>
print(render({"ok": True}))
~~~~~~^^^^^^^^^^^^^^
File "report.py", line 10, in render
return yaml.safe_dump(data)
^^^^
ModuleNotFoundError: No module named 'yaml'
exit=1
The chained traceback names both the lazy import line and the line that triggered it — good for debugging, useless if it first happens in production. “It starts” is no longer a test. Resolve every lazy binding in CI instead. Save as selfcheck.py next to report.py:
# selfcheck.py — fail fast in CI: resolve every lazy binding in a module
import importlib
import sys
import types
def reify_all(module_name):
mod = importlib.import_module(module_name)
failures = []
for name, value in list(vars(mod).items()):
if isinstance(value, types.LazyImportType):
try:
setattr(mod, name, value.resolve())
except ImportError as exc:
failures.append(f"{module_name}.{name}: {type(exc).__name__}: {exc}")
return failures
if __name__ == "__main__":
sys.argv = ["report.py"] # import report.py without its --yaml branch
problems = reify_all("report")
for p in problems:
print("LAZY IMPORT BROKEN:", p)
sys.exit(1 if problems else 0)uv run --python 3.15 python selfcheck.py; echo "exit=$?"Output:
started; lazy modules pending: ['yaml']
plain output: ok
LAZY IMPORT BROKEN: report.yaml: ModuleNotFoundError: No module named 'yaml'
exit=1
vars(module) (like globals() and module.__dict__) returns the raw namespace without reifying, so proxies are visible as types.LazyImportType and can be resolved explicitly with .resolve(). Normal attribute access (module.name, getattr) would trigger the import instead.
Case 3: One source file for 3.13, 3.14, and 3.15 with __lazy_modules__
Libraries that still support older Pythons cannot use the new syntax — it is a SyntaxError before 3.15. __lazy_modules__ is the bridge. Save as compat.py:
# compat.py — lazy on 3.15+, plain eager imports on older Pythons
__lazy_modules__ = {"decimal", "fractions"}
import sys
import decimal
from fractions import Fraction
ver = sys.version.split()[0]
print(f"{ver:10} before use: decimal={'decimal' in sys.modules} fractions={'fractions' in sys.modules}")
total = decimal.Decimal("1.10") + decimal.Decimal("2.20")
print(f"{ver:10} after Decimal: decimal={'decimal' in sys.modules} fractions={'fractions' in sys.modules} total={total}")uv run --python 3.13 python compat.py
uv run --python 3.15 python compat.pyOutput:
3.13.5 before use: decimal=True fractions=True
3.13.5 after Decimal: decimal=True fractions=True total=3.30
3.15.0rc2 before use: decimal=False fractions=False
3.15.0rc2 after Decimal: decimal=True fractions=False total=3.30
On 3.15 fractions is never loaded because Fraction was never used. List fully qualified module names (relative imports are resolved before the lookup), define the variable before the imports it governs, and remember it covers from m import x when m is listed.
Case 4: Type-only imports without if TYPE_CHECKING:
With deferred annotations (3.14+), an annotation is not evaluated until someone asks for it. Pair that with lazy from and an import used only in hints costs nothing at runtime — and stays a normal, visible import that type checkers understand.
mkdir -p shop && touch shop/__init__.py# shop/models.py — imagine this pulls in an ORM and takes 200 ms
print(" [shop.models imported]")
class Order:
def __init__(self, total):
self.total = total# shop/pricing.py — needs Order only for type hints
lazy from shop.models import Order
def apply_discount(order: Order, pct: int) -> Order:
order.total = order.total * (100 - pct) // 100
return order# main.py
import annotationlib
import sys
from shop import pricing
print("pricing imported; shop.models loaded?", "shop.models" in sys.modules)
class FakeOrder: # duck-typed: pricing never touches the real class
total = 1000
print("discounted:", pricing.apply_discount(FakeOrder(), 15).total)
print("after call; shop.models loaded?", "shop.models" in sys.modules)
hints = annotationlib.get_annotations(pricing.apply_discount)
print("annotations:", hints)
print("after hints; shop.models loaded?", "shop.models" in sys.modules)uv run --python 3.15 python main.pyOutput:
pricing imported; shop.models loaded? False
discounted: 850
after call; shop.models loaded? False
[shop.models imported]
annotations: {'order': <class 'shop.models.Order'>, 'pct': <class 'int'>, 'return': <class 'shop.models.Order'>}
after hints; shop.models loaded? True
The heavy module loads only when a tool (a validator, a serializer, inspect.signature) actually evaluates the annotations — and then it loads correctly instead of failing the way a TYPE_CHECKING-guarded name would.
Case 5: Application-wide laziness, scoped by a filter
The global mode is for applications, not libraries: it changes how other people’s modules import. Scope it with a filter so only your own package is affected.
mkdir -p myapp && touch myapp/__init__.py# myapp/slow.py
print(" [myapp.slow imported]")
VALUE = 42# run_all.py — application-wide laziness, scoped to our own package
import sys
def only_myapp(importer, name, fromlist):
return name == "myapp" or name.startswith("myapp.")
sys.set_lazy_imports_filter(only_myapp)
sys.set_lazy_imports("all")
import myapp.slow # lazy: matches the filter
import json # eager: filter returns False
print("mode:", sys.get_lazy_imports())
print("json loaded:", "json" in sys.modules, "| myapp.slow loaded:", "myapp.slow" in sys.modules)
print("value:", myapp.slow.VALUE)uv run --python 3.15 python run_all.pyOutput:
mode: all
json loaded: True | myapp.slow loaded: False
[myapp.slow imported]
value: 42
The same switch from outside, on a file that never mentions lazy:
# plain.py — no lazy keyword anywhere
import sys
import json
import myapp.slow
print("json loaded:", "json" in sys.modules, "| myapp.slow loaded:", "myapp.slow" in sys.modules)uv run --python 3.15 python plain.py
uv run --python 3.15 python -X lazy_imports=all plain.py
PYTHON_LAZY_IMPORTS=all uv run --python 3.15 python plain.pyOutput:
[myapp.slow imported]
json loaded: True | myapp.slow loaded: True
json loaded: False | myapp.slow loaded: False
json loaded: False | myapp.slow loaded: False
Precedence is sys.set_lazy_imports() over -X lazy_imports over PYTHON_LAZY_IMPORTS. The filter’s importer argument may be None; do not call string methods on it without checking.
The trap
Import-time side effects silently stop happening. Registries populated by decorators, __init_subclass__ hooks, codec or plugin registration, logging configuration, monkeypatches — all of them run when the module executes. Make that import lazy and the module may never execute.
# registry.py
HANDLERS = {}
def handler(name):
def deco(fn):
HANDLERS[name] = fn
return fn
return deco# csv_export.py — registers itself as an import side effect
from registry import handler
@handler("csv")
def export(rows):
return "\n".join(",".join(map(str, r)) for r in rows)# app.py
import sys
from registry import HANDLERS
lazy import csv_export # "it's only needed for its side effect..."
fmt = sys.argv[1] if len(sys.argv) > 1 else "csv"
fn = HANDLERS.get(fmt)
print("registered:", sorted(HANDLERS))
print(fn([(1, 2), (3, 4)]) if fn else f"error: no handler for {fmt!r}")uv run --python 3.15 python app.py
sed 's/^lazy import csv_export.*/import csv_export/' app.py > app_eager.py
uv run --python 3.15 python app_eager.pyOutput:
registered: []
error: no handler for 'csv'
registered: ['csv']
1,2
3,4
No exception, no warning — just a missing feature. Under -X lazy_imports=all the same thing can happen to every “import for its side effect” in every module — including modules that never opted in. Fixes, in order of preference: make discovery explicit (registry.load_plugins() imports what it registers); keep such imports eager and comment why; or exclude them in the filter.
Smaller traps worth knowing:
lazydoes not make imports cheaper — it moves them. The first request after a deploy, or the first iteration of a latency-sensitive loop, now pays the import. For long-lived servers, import eagerly at startup (or resolve in a warm-up hook) and keep laziness for CLIs, test collection, and rarely used paths.- Import state is read at first use.
sys.path,sys.meta_path, and environment variables are consulted when the proxy resolves, not when thelazy importline ran. Code that tweakssys.pathtemporarily around an import must stay eager. - Submodule attributes are not a side channel.
import pkgfollowed bypkg.sub.f()only ever worked because someone else importedpkg.sub. With lazy imports that “someone” may not have run yet. Alwaysimport pkg.subexplicitly. - Lazy is not a cycle breaker. Two modules that use each other at import time still fail; laziness only helps when the cross-references happen inside functions.
- Failed resolution retries. If resolution raises, the proxy stays in place and the next access tries again — a missing module can fail on every request instead of once at startup.
- Package installers and plugin loaders that
pip installinto the running environment should resolve or eagerly import everything first, so newly installed distributions cannot shadow modules that were merely promised.
The boring rule
Mark an import
lazyonly when the module is heavy, side-effect free, and not needed on the common path of a short-lived program (or used only in annotations). Keep imports at the top of the file, preferlazyover function-local imports, use__lazy_modules__while you still support Python < 3.15, reserve the global mode and filters for applications — and make CI resolve every lazy binding.
Try this
- Run
uv run --python 3.15 python -X importtime deploy_eager.py --help 2>&1 | sort -t'|' -k2 -n | tail -15and pick the three most expensive imports. Make only those lazy and measure withbench.py. - In
deploy.py, addlazy import urlliband useurllib.request.urlopenin a newfetchsubcommand. Observe what happens and fix it by importing the submodule explicitly. - Extend
selfcheck.pyto walk every module of a package withpkgutil.walk_packagesand wire it intopytestas a single test. - Write a filter for Case 5 that keeps lazy everything except a set of modules known to register handlers, then run
app.pyunder-X lazy_imports=alland confirm the handler registers. - Put
lazy import jsoninside anifblock and inside awithblock at module scope. Which compile, which are rejected, and why does thetryrestriction exist? - Use
dis.dis(fn, adaptive=True)on a function that callsjson.dumpsthrough a lazy global, before and after a few calls, and find the specialized instruction that replacesLOAD_GLOBAL.
Sources
- PEP 810 — Explicit lazy imports (Final, Python 3.15): https://peps.python.org/pep-0810/
- Python 3.15 Language Reference — Lazy imports and
__lazy_modules__: https://docs.python.org/3.15/reference/simple_stmts.html#lazy-imports - What’s New in Python 3.15 — PEP 810: https://docs.python.org/3.15/whatsnew/3.15.html
sys.set_lazy_imports,sys.get_lazy_imports,sys.set_lazy_imports_filter,sys.lazy_modules: https://docs.python.org/3.15/library/sys.html- Python 3.15.0rc3 release notes (final moved to 2026-10-09 for lazy-import fixes): https://www.python.org/downloads/release/python-3150rc3/