Lazy Imports (PEP 810)

Updated

October 31, 2026

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:

  • lazy is legal only at module scope — not in a function, a class body, or a try/except/finally block (each is a SyntaxError).
  • lazy from m import * and lazy from __future__ import … are errors.
  • lazy is a soft keyword: lazy = 3 still works everywhere else.
  • lazy from m import a, b binds two proxies; the first one touched loads all of m and resolves only that name.
  • Laziness is local and non-recursive: lazy import pkg does not make pkg’s own imports lazy.

Two compatibility and control knobs:

  • __lazy_modules__ — a module-level container of fully qualified module names. On 3.15+ a plain import of a listed module behaves as if it had lazy; on 3.14 and older the variable is just an unused global.
  • Global mode — -X lazy_imports=all, PYTHON_LAZY_IMPORTS=all, or sys.set_lazy_imports("all") make every eligible module-level import potentially lazy; "normal" (the default) honours only lazy and __lazy_modules__. A filter, sys.set_lazy_imports_filter(fn), gets (importer, name, fromlist) and returns False to 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 web

Output:

{"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.py

Output 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.py

Output:

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.py

Output:

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.py

Output:

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.py

Output:

  [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.py

Output:

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:

  • lazy does 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 the lazy import line ran. Code that tweaks sys.path temporarily around an import must stay eager.
  • Submodule attributes are not a side channel. import pkg followed by pkg.sub.f() only ever worked because someone else imported pkg.sub. With lazy imports that “someone” may not have run yet. Always import pkg.sub explicitly.
  • 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 install into 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 lazy only 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, prefer lazy over 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

  1. Run uv run --python 3.15 python -X importtime deploy_eager.py --help 2>&1 | sort -t'|' -k2 -n | tail -15 and pick the three most expensive imports. Make only those lazy and measure with bench.py.
  2. In deploy.py, add lazy import urllib and use urllib.request.urlopen in a new fetch subcommand. Observe what happens and fix it by importing the submodule explicitly.
  3. Extend selfcheck.py to walk every module of a package with pkgutil.walk_packages and wire it into pytest as a single test.
  4. Write a filter for Case 5 that keeps lazy everything except a set of modules known to register handlers, then run app.py under -X lazy_imports=all and confirm the handler registers.
  5. Put lazy import json inside an if block and inside a with block at module scope. Which compile, which are rejected, and why does the try restriction exist?
  6. Use dis.dis(fn, adaptive=True) on a function that calls json.dumps through a lazy global, before and after a few calls, and find the specialized instruction that replaces LOAD_GLOBAL.

Sources