Python 3.15 adds a lazy keyword that binds a proxy
instead of running an import
First use of the name reifies the proxy into the real module, moving an import's errors and side effects out of startup and into the running program.
Python already defers thousands of imports by hand
The CPython standard library carries nearly 3,500 imports written inside function bodies instead of at the top of the file: about 17 percent of every import outside its test suite, spread across 730 files, each placed there to keep a module from loading until the code that needs it runs.1 That hand-written pattern is what Python 3.15 turns into a keyword.
Writing lazy before an import or a
from ... import statement tells the interpreter to defer
the work. The name is bound, but the module is not loaded until the
name is first used.1
The target is startup cost. A command-line tool that imports a heavy
optional dependency for one code path in ten pays for that import on
every run; deferring it until the path that needs it runs removes the
cost from the other nine.1
The feature is accepted and shipping, and not yet stable. The Python Steering Council accepted PEP 810 unanimously on 3 November 2025.2 The implementation landed in CPython on 12 February 20263 and has ridden the beta and release-candidate series since; the feature's documentation is live in the 3.15 docs served at release candidate 3.4 As of this writing the interpreter is at 3.15.0rc3, dated 2 October 2026, with 3.15.0 final scheduled for 9 October.5
A lazy import binds a proxy, not a module
The confusion to clear first is between two facts that ordinary
English blurs: a name is bound, and a module has been imported. A
normal import does both. It runs the module's code, places the module
object in sys.modules, and binds a name to it. A lazy
import stops after the binding.1
lazy import json calls __lazy_import__ in
place of the usual __import__. It binds the name
json to a types.LazyImportType proxy and
records the fully-qualified name in sys.lazy_modules, a
set the specification keeps for diagnostics and introspection. The
module stays absent from sys.modules until the proxy
reifies.1
lazy import json
import sys
# Look at the binding without using it. Fetching the name through
# globals() returns the proxy and does not reify it.
type(globals()["json"]) # <class 'LazyImport'>
"json" in sys.lazy_modules # True: recorded for introspection
"json" in sys.modules # False: the module is not imported yet
lazy import json, the name exists and the module
does not. The behaviour each comment shows is the one the
specification defines; no 3.15 interpreter was run here.1
The line that carries the point is the first one. Fetching the name
through globals() is the only way to look at the proxy
without consuming it, because globals() and a module's
__dict__ return the binding without reifying it.1
Every other access reifies.
First use turns the proxy into the module
Reification is the moment the proxy becomes the module. Accessing a
lazy object, as a global variable or as a module attribute, reifies
it: the deferred import runs, the real module object takes the proxy's
place in the namespace and enters sys.modules, and the
access returns the real object.1
Continue the same session, and the first real use of
json is where its import finally happens.
json.dumps({"ok": True}) # first use: the import runs here
type(globals()["json"]) # <class 'module'>
"json" in sys.modules # True
json is its first use, so the import
runs at that line and the proxy is gone afterwards.1
First use is any ordinary use of the name: calling through it, reading
an attribute, passing it somewhere it will be touched. The two
accesses that do not count are the ones that let tooling inspect a
lazy binding, reading it out of globals() or a module's
__dict__.1
A program that needs the import to happen at a known point can force
it by calling the proxy's resolve() method, which reifies
without otherwise using the module.1
A deferred import already broke Mercurial
Deferral moves two things in time. An exception that an eager import would raise at import time now raises at first use of the lazy name, and an import-time side effect in the lazy module runs at first use rather than at module import.1 For most modules neither matters, because a module's top level only defines things. The case that breaks is a module whose top level does work something depends on by the time import returns: it registers a codec, mutates a global, patches another module, opens a connection. Defer that import and the work does not happen when the importing module finishes loading. It happens later, at first use of the name, or never, if the name is never used.
Whether that is a bug turns on a fact the interpreter cannot see:
whether any code between the import and the first use relied on the
side effect having already run. If nothing did, the deferral is
invisible and the startup cost is gone. If something did, the program
is wrong in a way that moves with the first use. That is why eager is
the safe default and lazy is opt-in per statement.1
This is not hypothetical. In March 2026, during the pre-release
series, Mercurial hit it.6
Mercurial ships its own lazy-import system, hgdemandimport, built on a
custom meta-path import hook; the 3.15 standard library now uses PEP
810 to import some of its own attributes lazily, and the two collided.
The symptom was exact:
cannot import name 'nlargest' from 'heapq'. Mads
Kiilerich's fix added heapq and copy to
hgdemandimport's ignore list, so Python's own lazy imports could
resolve on their own terms.6
The episode shows two things at once: the 3.15 standard library is
already importing lazily in shipped code, and the first real conflicts
are being found and fixed against the release candidates.
Where the keyword is barred, and how to force eager
The keyword is a module-level statement. It raises
SyntaxError inside a function, inside a class, and inside
a try/except block, and lazy from ... import * is not
allowed, since star imports stay eager.1
One restriction a reader will see stated wrongly: the implementation
PR's prose summary bars lazy imports inside both try/except and
with statements. The PEP bars only try/except. It
explicitly rejected disallowing lazy inside
with blocks, reasoning that with is used for
resource management and has broader semantics than try/except.1
The with block is allowed; the PR blurb is the secondary
account and loses to the specification.3
lazy import json # ok at module level
lazy from pathlib import Path # ok
with open("config.toml") as f: # ok: with blocks are allowed
lazy import tomllib
def load():
lazy import json # SyntaxError: not inside a function
class Loader:
lazy import json # SyntaxError: not inside a class
try:
lazy import json # SyntaxError: not inside try/except
except ImportError:
pass
lazy from os import * # SyntaxError: star imports stay eager
SyntaxError, including the with block the
PR summary gets wrong.1
A project can override the keyword globally. One setting decides whether the keyword is honored at all, in three values.
| Mode | Effect |
|---|---|
| normal |
Honor the lazy keyword; every other import is
eager. This is the default.
|
| all | Treat every import as potentially lazy, keyword or not. |
| none | Force every import eager and ignore the keyword. |
The mode can be set three ways, and they override in a fixed order:
sys.set_lazy_imports() wins, then the
-X lazy_imports interpreter flag, then the
PYTHON_LAZY_IMPORTS environment variable;
sys.get_lazy_imports() returns the mode in force.1
Two finer controls sit beside the modes.
# A module can list fully-qualified names to defer, without the
# keyword on each line. ["*"] is rejected; names must be real.
__lazy_modules__ = ["numpy", "pandas"]
# Force a whole run eager, ignoring the keyword:
# python -X lazy_imports=none app.py
# PYTHON_LAZY_IMPORTS=none python app.py
__lazy_modules__ opt-in and the per-run overrides.1
The last control is a filter.
sys.set_lazy_imports_filter(func) installs a callable
with the signature
func(importer, name, fromlist) -> bool that the
interpreter consults per import; passing None removes
it.1
When a lazy import earns its place
The explicit keyword is the second lazy-imports design to reach the interpreter from the same source. PEP 690, drawn from Meta's Cinder work, proposed making imports lazy implicitly and globally, through a flag rather than a keyword; it was rejected.7 PEP 810 answers the objection that sank it. Laziness is visible at the import statement, chosen per line, and reasoned about where it sits, instead of a global switch that changes what every import in a program means.1
The gains that motivated the work are real, and they come from that earlier implicit design, measured at Meta. Instagram's server loads about 28,000 modules at startup. Cinder's implicit lazy imports cut its reload time by about 70 percent at the median and about 60 percent at the 90th percentile. They loaded roughly 12 times fewer modules and drove a class of daily circular-import errors from around 80 a day to zero.8
Those are Instagram's web-server figures from 2022. A later Meta report on machine-learning workloads, again on the implicit Cinder design, saw up to 40 percent faster time to first batch and a 20 percent cut in Jupyter kernel startup, a separate set of metrics on a separate workload.9
PEP 810 makes no such claim for its keyword. Its own performance statement is the opposite kind: the feature is performance-neutral, with no measurable overhead at import and zero overhead once a name has reified.1 The keyword does not make a program faster. It lets a program avoid paying for what it does not use, and whether that is worth anything depends on how much the deferred import costs and how often the program skips it.
So each lazy answers one narrow question: does this import
cost enough at startup, and go unused often enough, to be worth the
risk that its errors and side effects move to first use. For a heavy
optional dependency on a cold path, the answer is usually yes. For a
module whose top level registers something the program needs, eager
import is still the only correct choice, and the interpreter keeps it
the default for that reason.1
Sources
-
peps.python.org · "PEP 810 – Explicit lazy imports"
(Status: Final; Resolution 3 November 2025) — the
specification: the
lazysoft keyword, thetypes.LazyImportTypeproxy andsys.lazy_modules, the reification rules andresolve(), the placement restrictions and the rejectedwith-block ban, the error and side-effect timing, the modes and their precedence,__lazy_modules__, the filter, and the performance-neutral statement and Motivation figures - discuss.python.org · Barry Warsaw for the Python Steering Council, PEP 810 acceptance post (3 November 2025) — the unanimous acceptance and its attached conditions
-
GitHub · python/cpython PR #142351, "gh-142349: Implement
PEP 810 - Explicit lazy imports" (merged 12 February 2026) —
the implementation landing on main; its prose summary misstates
the
with-block restriction - docs.python.org · "What's New In Python 3.15" → "PEP 810: Explicit lazy imports" (served at 3.15.0rc3) — the shipped documentation, confirming the feature is implemented and live in the pre-release docs
- peps.python.org · "PEP 790 – Python 3.15 Release Schedule" (Hugo van Kemenade) — the actual dates: beta 1 (7 May 2026) through candidate 3 (2 October 2026) and 3.15.0 final (9 October 2026)
-
mercurial-devel · Mads Kiilerich, hgdemandimport and PEP
810 patch (28 March 2026) — the collision between
hgdemandimport's import hook and the 3.15 standard
library's own lazy imports, the
cannot import name 'nlargest' from 'heapq'failure, and theheapq/copyignore-list fix - peps.python.org · "PEP 690 – Lazy Imports" (Status: Rejected; Python 3.12) — the earlier implicit, global proposal from Meta's Cinder work that PEP 810's explicit keyword replaced
- developers.facebook.com · Germán Méndez Bravo, "Python Lazy Imports With Cinder" (15 June 2022) — Instagram Server's ~28,000-module startup and the implicit Cinder results: reload-time, module-count, memory, and circular-import figures
- engineering.fb.com · Germán Méndez Bravo, "Lazy is the new fast: How Lazy Imports and Cinder accelerate machine learning at Meta" (18 January 2024) — the implicit Cinder results on ML workloads: time to first batch and Jupyter kernel startup