· 5 min read
The delay belongs in the import
Python 3.15's explicit lazy imports make deferred module loading visible at the line that moves work, side effects, and failures to first use.

In 2022 Python had a lazy-import proposal that tried hard to hide the laziness. Start the interpreter with a flag, keep writing ordinary imports, and let the runtime defer top-level modules until somebody touched the imported name. PEP 690 called the goal transparent. It was rejected.
Three years later, PEP 810 was accepted for Python 3.15 with a conspicuous extra word: lazy import json. The performance idea barely needs explaining. The word does. A lazy import moves module execution, import errors, and import-time side effects from the import line to first use. I want that timing decision sitting in the source where a reviewer can see it.
The name arrives before the module#
Python's current 3.15 documentation makes the split unusually easy to inspect. The import statement binds a name immediately, but the target module does not enter sys.modules until the name is first used. The temporary value is a types.LazyImportType proxy.
import syslazy import jsonprint("json" in sys.modules) # Falsepayload = json.dumps({"ready": True})print("json" in sys.modules) # TrueBefore the call to json.dumps, the importing module already has something named json. It just has not paid for the real module yet.
Before the first use, the importing module owns a proxy while json is still absent from sys.modules.
- module globals: json points to LazyImportType; sys: module
- LazyImportType: target: json; state: unresolved
- sys.modules: json: absent
That implementation detail matters because the earlier proposal put more of the trick in dictionaries. PEP 690 wanted lazy placeholders to be unobservable, so module dictionary lookup had to notice them and resolve them before Python or an extension could get hold of one. Even iteration over a dictionary needed special handling so a lazy object would not leak out halfway through the walk.
PEP 810 gives up on that particular kind of transparency. The proxy type exists, debuggers can encounter it, and the normal dictionary stays a normal dictionary. After the first access, the binding behaves like an eager import again. That is a much smaller magic trick.
Python programmers were already moving the imports#
The accepted PEP is responding to code people already write. Its authors counted roughly 3,500 imports across 730 standard-library files outside tests and found about 17 percent placed inside functions or methods specifically to defer execution. Moving an import down into the function works, but it hides part of the module's dependency list and makes one refactor capable of pulling the cost back into startup.
The command-line case is the obvious one. A tool may have a report generator, a local server, an exporter, and several optional backends, while --help needs almost none of them. PEP 810 keeps those dependencies at module scope and lets the entry point say which ones can wait:
import argparselazy from .report import build_reportlazy from .serve import run_serverparser = argparse.ArgumentParser()parser.add_argument("command", choices=["report", "serve"])I have not benchmarked the 3.15 implementation against a large production CLI, so I would start with an import profile rather than converting a package by taste. The PEP's useful contribution is that the optimization can now stay beside the dependency instead of being encoded as a tour of function bodies.
The side effect moved too#
Laziness becomes risky when an import is doing more than making names available. Plenty of Python modules register plugins, install codecs, configure logging, populate registries, or probe the environment in top-level code. Mark one of those imports lazy and the registration happens later. Never touch the imported name and it may never happen at all.
The accepted PEP says this directly: errors and side effects occur at first use. Python's grammar then puts a useful fence around one awkward case. A lazy import inside try, except, or finally is a syntax error. If loading is deferred until some unrelated call hours later, the original exception handler cannot honestly promise to catch the ImportError anymore.
The current documentation compensates for that moved failure with a traceback that includes both the point of first use and the original import statement. Good. But the traceback cannot restore an initialization order a package was quietly relying on. A module whose import is part of application startup should probably remain eager until that work has a less surprising home.
The global switch is a probe#
Python 3.15 also has -X lazy_imports=all, PYTHON_LAZY_IMPORTS, and a filter API that can force selected imports back to eager loading. The 3.15 release documentation presents these as broader controls around the same mechanism. They are useful for finding how much startup work a program can skip and for testing an application's assumptions. I would be cautious about treating the global mode as the final design of a library, because it removes the best property the new syntax bought: you can no longer tell from the import line when the module body is supposed to run.
The compatibility hook points in the other direction. A module can list names in __lazy_modules__so Python 3.15 treats ordinary imports of those modules as lazy while older interpreters ignore the declaration. That gives maintainers a migration bridge without asking every downstream application to flip its whole import graph at once.
The relay in the header sits idle until a control signal arrives, and nobody mistakes that delay for an ordinary wire. I want the same courtesy from an import. Run the CLI with --help and the report backend can stay absent from sys.modules; choose report and it appears when the name is touched. The work moved in time, so the source should say so.