
The number attached to PEP 810 is 50 to 70%, from Meta and Hudson River Trading, and it is a startup number. The question a working program has is the other one: what does an import cost after it has been marked lazy and then used.
Measured on 3.15.0rc1, an eager import asyncio costs 26.8 ms. Marking it lazy costs nothing
that can be told apart from an empty interpreter. Reading the name once costs 26.4 ms. The
statement got cheaper; the program did not.
What the three programs cost
Three programs per module, each in its own process: an eager import, a lazy import that is never read, and a lazy import whose name is read once. Process startup noise is one-sided — the scheduler only adds — so each figure is the fastest of fifteen processes, with the bare interpreter’s own spread printed above the table so a small number can be judged against it:
$ python3 experiments/importtime/lazy.py ~/.pyenv/versions/3.15.0rc1/bin/python3
python 3.15.0rc1 on darwin
bare interpreter: 9.1 ms, 33 modules, best of 15 processes
that baseline across the 15: best 9.1, median 9.4, slowest 10.0 ms
module eager deferred touched modules eager/deferred
json 3.5 ms -0.2 ms 4.1 ms 25 / 0
logging 9.3 ms -0.6 ms 8.7 ms 51 / 0
dataclasses 3.0 ms -0.4 ms 2.4 ms 10 / 0
typing 2.8 ms -0.5 ms 2.5 ms 15 / 0
asyncio 26.8 ms -0.4 ms 26.4 ms 115 / 0
unittest 14.6 ms -0.0 ms 14.5 ms 72 / 0
six lazy imports, k of them read; all six eager is 32.5 ms and 136 modules
k ms modules saved against all-eager
0 -0.4 ms 0 101%
1 4.0 ms 25 88%
2 10.3 ms 56 68%
3 12.1 ms 60 63%
4 13.6 ms 62 58%
5 30.3 ms 122 7%
6 32.6 ms 136 -0%
ns per read of a name after reification: 5.68 once lazy, 5.83 always eager (best of 7 alternating rounds x 1,000,000)
The deferred column is negative on five of six rows. That is not a saving over an empty
interpreter; it is the measurement saying the deferred import is smaller than the 0.9 ms the
bare interpreter itself moves by across fifteen processes. The module count is the column
without an error bar: import asyncio adds 115 entries to sys.modules and lazy import asyncio adds none. Those 115 are the subtree an import pulls in behind the one name you asked
for, which is what the statement actually does.
The touched column is the one a startup figure does not include, and it is within noise of
eager on every row. Deferral moved the cost. It did not reduce it.
What the name is bound to
Before the first read, the global is not a module and the module is not loaded:
import sys
lazy import asyncio
g = globals()
print(type(g["asyncio"]).__name__)
print("asyncio" in sys.modules)
asyncio.run # the first read reifies it
print(type(g["asyncio"]).__name__, "asyncio" in sys.modules)
$ ~/.pyenv/versions/3.15.0rc1/bin/python3 lazyproxy.py
lazy_import
False
module True
The binding is a types.LazyImportType, and sys.modules has no entry for the module at all,
which is why the module count in the table is zero rather than small. The first read of the
name resolves the import and replaces the proxy with the real object; from that point the
global is indistinguishable from an eagerly imported one, which the 5.68 ns against 5.83 ns at
the bottom of the output confirms rather than assumes.
Reading is broader than it looks. type(asyncio) reifies. print(asyncio) reifies. The only
way to see the proxy is to fetch it out of globals() by key, which is not a name read.
PEP 810 puts the mark at module scope and nowhere else: it is a syntax error inside a function,
inside a class, and inside a try or except block, and lazy from X import * is rejected.
The saving is the imports you do not read
The lower table is the same six modules imported lazily in one program, with the first k of them read. Four unread out of six saves 58%. Five read out of six saves 7%.
The difference between those two rows is one import, and it is not a fifth of the work. The
fifth module in the list is asyncio, which is 26.8 of the 32.5 ms the eager version costs.
Deferring five cheap imports and reading the expensive one saves almost nothing; deferring the
expensive one and reading five cheap ones saves most of the startup.
So the count of deferred imports predicts nothing. What it saves is the sum of the ones that go unread on that invocation, which is a property of the code path rather than of the import block. The five cheap modules are 5.7 ms of the 32.5 between them, and the rows in the middle of that table are the arithmetic of which ones happened to be read rather than a curve of any shape.
There is a second way to declare the same thing. A module can set __lazy_modules__ to a
container of fully qualified module names, and on 3.15 the interpreter asks it, with
__contains__, whether an import should be made lazy. On earlier interpreters the name is an
ordinary global that nothing reads, which is what makes it the form a library can ship today.
It is not measured here; the numbers above are all the keyword.
Choosing which imports to mark
Take -X importtime, sort by cumulative, and look at the top three. For each one, ask whether
the common invocation reads the name. A CLI whose --help path never touches its HTTP client
is the case this feature was built for, and the saving there is the client’s whole import cost.
A web worker that imports its ORM and then serves a request with it is not: the mark is free,
and it buys nothing.
The mark is not free of every cost. An import that fails now fails at first read, on whatever code path gets there first, rather than at startup where a crash is unambiguous. That is the trade, and it is worth taking exactly where the saving is real.
Where to stop
One machine, macOS on Apple silicon, one interpreter: 3.15.0rc1 built from source on 2 September 2026. The final release is dated 1 October and these figures are not it.
Everything measured here is standard library. A real application’s imports are third-party packages with import graphs of their own, and nothing in this table says how those behave.
Memory is not measured. PEP 810 cites 30 to 40% savings from the same case studies as the
startup figure, and this harness reports module counts, not resident bytes. The 115 modules
asyncio does not load are real; what they would have cost in memory is not a number this
entry has, and getting that number right is its own
problem.
Frequently asked
Should every import in a CLI be marked lazy?
Only the ones a given invocation will not read. An import that is always read pays the same total and adds a proxy in front of it. The measurement here puts a program that reads all six of its lazy imports within measurement noise of the all-eager version, so the mark buys nothing there and costs nothing either.
Where can lazy not be used?
PEP 810 restricts it to module scope. It is rejected inside functions, inside classes and inside try or except blocks, and lazy from X import * is not allowed. Those restrictions are syntax errors, not warnings, so a mark in the wrong place fails at compile time rather than silently doing nothing.
Does the deferred import cost anything once it has been resolved?
No. The proxy is replaced by the real object at the first read, so what follows is an ordinary global read. Timed in alternating rounds in one process, a name that was lazy reads at 5.68 ns and a name that was always eager at 5.83.
What happens if the deferred import fails?
The ImportError surfaces at the first read of the name rather than at the import statement, and the PEP specifies that a failed reification leaves the proxy in place so the next read tries again. That moves an import failure from startup, where it is obvious, to whatever code path first touches the name.
Are these numbers final?
They are 3.15.0rc1, built from source on 2 September 2026. The final release is dated 1 October, and this archive re-runs its own figures on a release rather than assuming a candidate is one.
Where this came from
Run it yourself — every figure above came out of these:
- importtime/lazy.py — What a lazy import defers, by number of attributes touched
Read, rather than assumed:


