The Nightly Build.
Technical

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

bound, not imported · illustrativepython
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
After 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.

first use reifies · illustrativepython
json.dumps({"ok": True})     # first use: the import runs here
type(globals()["json"])      # <class 'module'>
"json" in sys.modules        # True
The call through 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

where lazy is allowed · illustrativepython
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
The allowed placements and the four that raise 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.

The three lazy-import modes a project can set globally.1
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.

opting in without the keyword · illustrativepython
# 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
The __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

~70%P50 RELOAD TIME
~12×FEWER MODULES
80→0CIRCULAR-IMPORT ERRORS

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

  1. peps.python.org · "PEP 810 – Explicit lazy imports" (Status: Final; Resolution 3 November 2025) — the specification: the lazy soft keyword, the types.LazyImportType proxy and sys.lazy_modules, the reification rules and resolve(), the placement restrictions and the rejected with-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
  2. discuss.python.org · Barry Warsaw for the Python Steering Council, PEP 810 acceptance post (3 November 2025) — the unanimous acceptance and its attached conditions
  3. 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
  4. 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
  5. 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)
  6. 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 the heapq/copy ignore-list fix
  7. 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
  8. 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
  9. 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