Python 3.15 lazy import: which lines still load the module

A normal import heavy runs heavy.py on the spot, which is why a program with many imports is slow to start. Python 3.15 adds lazy import heavy, which postpones that work. It binds the name heavy to a placeholder object (a proxy), and the module runs the first time something reads that name. Almost any read counts, including heavy is None and type(heavy). The reads that leave the proxy alone go through globals().

The feature is PEP 810, shipped in Python 3.15, and it exists to make programs start faster by skipping imports a run never needs. The PEP claims startup reductions of 50-70% for command-line tools. One line elsewhere in a module can cancel that, and nothing warns about it. On the 3.15 release thread on Hacker News (318 points), frodowtf2 asked “Is there any reason to not enable it globally?” Reply from zem: import-time side effects, “particularly intentional ones like the registry pattern.” Whether a line triggers the import is the other half of that answer.

All output below came from the python:3.15 Docker image (Python 3.15.2). The experiments use one helper module that announces when it runs.

# heavy.py
print("  [heavy.py executed]")
VALUE = 42

What the name holds before first use

This script checks the name’s type and sys.modules (the dict of modules already imported) before and after one plain read.

# check.py
import sys
lazy import heavy

print(type(globals()["heavy"]).__name__)
print("heavy" in sys.modules)
x = heavy
print(type(globals()["heavy"]).__name__)
print("heavy" in sys.modules)

Output:

lazy_import
False
  [heavy.py executed]
module
True

Before the assignment the name holds a lazy_import object and nothing is in sys.modules. The assignment x = heavy runs heavy.py and replaces the proxy with the real module. The language reference says the module loads “on first use of that name”. Rodrigo Girao Serrao found the same thing in the REPL: globals()["mod"] returns the proxy, and print(mod) loads the module.

Which operations load it

Each operation below ran in a fresh namespace with lazy import heavy executed first, then a check of sys.modules.

# ops.py
import sys
ops = {
 "alias = heavy": "alias = heavy",
 "{'h': heavy}": "d = {'h': heavy}",
 "type(heavy)": "type(heavy)",
 "isinstance(heavy, object)": "isinstance(heavy, object)",
 "heavy is None": "heavy is None",
 "repr(heavy)": "repr(heavy)",
 "heavy.VALUE": "heavy.VALUE",
 "getattr": "getattr(heavy, 'VALUE')",
 "hasattr nope": "hasattr(heavy, 'nope')",
 "globals()['heavy']": "globals()['heavy']",
 "dict(globals())": "dict(globals())",
 "dir()": "dir()",
 "globals().values()": "[v for v in globals().values()]",
 "lambda": "f = lambda: heavy.VALUE",
}
for label, code in ops.items():
    sys.modules.pop("heavy", None)
    ns = {}
    exec("lazy import heavy", ns)
    exec(code, ns)
    print(f"{label:30} {'loaded' if 'heavy' in sys.modules else 'still lazy'}")
Operation Result
alias = heavy loaded
{'h': heavy} loaded
type(heavy) loaded
isinstance(heavy, object) loaded
heavy is None loaded
repr(heavy) loaded
heavy.VALUE, getattr(heavy, 'VALUE'), hasattr(heavy, 'nope') loaded
globals()['heavy'] still lazy
dict(globals()) still lazy
dir() still lazy
iterating globals().values() still lazy
defining lambda: heavy.VALUE still lazy

Normal name lookup resolves the proxy, even for operations that never look inside the module. Reading the namespace dictionary does not. PEP 810 says the same: calling globals() “does not trigger reification”.

Why a dispatch table loads everything

Brett Cannon’s post on CLI subcommands (21 February 2026) warns that storing a lazy import in a dict or passing it to argparse loads the module, because assignment reads the name. The dict row above confirms it. This shape fails:

lazy import heavy
table = {"go": heavy}          # reads the name: heavy.py runs here

Wrapping the read in a lambda defers it, which the last table row showed. disp.py confirms it end to end:

# disp.py
import sys
lazy import heavy
table = {"go": lambda: heavy.VALUE}
print("heavy" in sys.modules)
table["go"]()
print("heavy" in sys.modules)

It prints False, then [heavy.py executed], then True. Cannon’s other fix is a match on the chosen subcommand, so only one branch ever reads its name.

A dispatcher that registers every handler up front is the usual place this bites. A diff that adds lazy there looks like an optimization and changes nothing. Compare python -X importtime output with and without the keyword:

$ python -X importtime it.py        # it.py is: lazy import heavy
(no line for heavy)
$ python -X importtime it2.py       # it2.py is: import heavy
import time:       771 |        771 | heavy

Checking without triggering

To ask whether a name is still pending, look it up through globals() and compare the type, as check.py does. 'heavy' in sys.modules answers a different question: has the module run anywhere in the process. An eager import heavy as h2 elsewhere runs it, yet the lazy name stays a proxy until read.

# eager.py
import sys
lazy import heavy
import heavy as h2
print(type(globals()["heavy"]).__name__, "heavy" in sys.modules)
heavy
print(type(globals()["heavy"]).__name__)

Output: [heavy.py executed] at the eager import, then lazy_import True, then module. So sys.modules answers “has the module run”, and the type check answers “has this name been resolved”.

Errors move to first use

lazy import nosuchmod
lazy from json import dumsp
print("imports done")

Both lines succeed. Reading nosuchmod raises ModuleNotFoundError: No module named 'nosuchmod'. Reading dumsp raises ImportError: cannot import name 'dumsp' from 'json', whose __cause__ is ImportError("lazy import of 'json.dumsp' raised an exception during resolution"). The reference documents this deferral. A typo in a rarely used branch now fails at the moment of use instead of at startup, so a test that imports each module and calls its entry points is the safety net.

The statement is limited to module scope. Compiling three cases gives these messages:

Source Error
lazy import json inside def f(): lazy import not allowed inside functions
lazy import json inside try: lazy import not allowed inside try/except blocks
lazy from json import * lazy from ... import * is not allowed

The optional-dependency pattern (try: import numpy) cannot become lazy.

Global switches

Two other ways to get lazy imports were tested, using the same heavy.py.

# glob.py
__lazy_modules__ = ["heavy"]
import heavy
print(type(globals()["heavy"]).__name__)

This prints lazy_import: a plain import heavy is treated as lazy when its name is listed in __lazy_modules__, which older Pythons ignore. Running python -X lazy_imports=all on a script with a plain import heavy also leaves lazy_import bound and heavy out of sys.modules. The -X flag makes imports lazy without editing the source, which is why zem’s side-effect warning applies: a module that registers itself on import (a plugin registry, a decorator that fills a table) never registers if nothing reads its name. Facebook’s analyzer Lifeguard, mentioned in that thread, is aimed at finding safe places for it, though the thread tags it as alpha. sys.set_lazy_imports_filter was not run, and its behavior here comes from the PEP.

What to do

Use lazy import for module-level imports that only some code paths need, and confirm the win once with -X importtime. Keep the imported name out of dicts, lists, and argparse defaults; read it inside a lambda or a match branch. Skip the feature in code that must run on older Pythons for the speedup. In a discuss.python.org thread about a CLI supporting 3.11 to 3.15, Click maintainer David Lord says __lazy_modules__ is backwards compatible in that code still parses and runs, but the benefits only appear on 3.15.