TL;DR
PEP 810 – Explicit Lazy Imports is Final and ships in Python 3.15. It adds a lazy import statement that defers a module’s real import work until the first time you touch the name. I benchmarked the payoff on Python 3.12: a small CLI that imports boto3 at the top takes 394 ms to print its version, and 48 ms if the import is deferred until the command that actually needs S3. That’s roughly an 8× startup win on the common path, and it costs nothing measurable when you do reach the heavy code. You can get most of this today with function-level imports and importlib.util.LazyLoader; PEP 810 just makes it a clean, top-of-file declaration.
The startup tax you don’t see on the invoice
Every import at the top of a module is a bill you pay on process start, whether or not the run touches that code. For a long-lived web server, who cares: you pay once and amortize it over millions of requests. For a CLI, a pre-commit hook, a Lambda cold start, or a Jupyter kernel, that bill lands on every single invocation, and it is the first thing your users feel.
I wanted numbers instead of hand-waving, so I measured the cost of a few common imports on my machine (Python 3.12.3, Linux, best of 15 cold subprocess runs each):
| Import | Startup cost over a bare interpreter |
|---|---|
import rich | +7 ms |
import click | +32 ms |
import pydantic | +78 ms |
import requests | +218 ms |
import boto3 | +366 ms |
import boto3 + build an S3 client | +607 ms |
A bare python -c "pass" was about 52 ms. So boto3 alone is 7× the interpreter’s own start time, and once you construct a client you are past 650 ms before your program does anything. requests, the library everyone reaches for without thinking, adds a fifth of a second on its own.
Where does boto3’s time go? python -X importtime gives an honest breakdown. Here are the heaviest cumulative offenders:
$ python -X importtime -c "import boto3" 2>&1 | sort ...
cumulative_us module
353236 boto3
267093 boto3.compat
254453 s3transfer.manager
143341 s3transfer
116745 botocore.compat
105736 s3transfer.copies
103285 botocore.httpchecksum
90546 botocore.model
Almost none of that is code a mycli version command will ever run. It is botocore and s3transfer machinery, eagerly loaded on the off chance you upload a file. That gap, heavy imports paid up front but needed rarely, is exactly what lazy imports close.
What PEP 810 actually does
PEP 810 adds a soft keyword, lazy, that you place in front of an import:
lazy import boto3
lazy from json import dumps
When Python runs one of these, it doesn’t import the module. It binds a lightweight placeholder object (types.LazyImportType) to the name. The real import runs the first time that name is used, on an attribute lookup or a plain reference. The PEP calls this “reification”: the placeholder resolves the import and replaces itself with the concrete module. Touch boto3.client and the import fires right then; never touch boto3 and it never loads.
A few design choices matter in practice, and they are the details you only learn by reading the spec rather than the headline:
- Module scope only.
lazy importis allowed at module top level, not inside functions or class bodies. Writing it inside a function raises aSyntaxError; lazy imports have to live at module level. That’s the opposite of the manual trick most of us use today, and it’s deliberate: the lazy declaration stays visible at the top of the file where imports belong. - It is opt-in, twice over. Per-statement with the keyword, or process-wide with
-X lazy_imports=<mode>, thePYTHON_LAZY_IMPORTSenvironment variable, orsys.set_lazy_imports(mode), where mode is"normal","all", or"none". There is also a per-module__lazy_modules__list for declaring names lazy without the keyword. - Some things stay eager, always. Star imports (
lazy from os import *),from __future__imports, and imports insidetry/exceptare never lazy. That last case stays eager because code that wraps an import intryusually depends on theImportErrorfiring at that line, and silently deferring it would move the failure somewhere confusing. - Introspection does not spring the trap. Calling
globals(),dir(), or readingframe.f_globalsdoes not reify a lazy import. Only real use does. That keeps debuggers and tooling from accidentally forcing every deferred import to load.
Status is Final, Python-Version is 3.15, and the Steering Council resolved it on 3 November 2025. Python 3.15 is targeting a stable release in October 2026 (I walked through the rest of the 3.15 changes separately), so this is a “prepare now, flip the switch later” feature, and the betas already carry it.
How much do lazy imports save?
Two numbers get quoted a lot, and both deserve the “reported” label because I didn’t run them myself. Meta’s Cinder team, whose lazy-import work motivated much of this design, has reported time-to-first-batch wins of up to 40% on machine-learning workloads after adopting lazy imports. And Hugo van Kemenade, a CPython core dev, published a write-up titled “Three times faster with lazy imports” measuring a real CLI startup. Read those as ballpark figures from someone else’s codebase; yours will land somewhere of its own.
For a number I can stand behind, I built the smallest realistic thing: a CLI with two commands. version prints a string. upload builds an S3 client. In the eager version, boto3 is imported at the top. In the lazy version, the import is deferred until upload runs. Then I measured the common path (the user running version, which never needs S3), plus the path that does.
# eager.py — the normal way
import sys
import boto3 # paid on every invocation, even `version`
def main():
if sys.argv[1:] == ["upload"]:
boto3.client("s3", region_name="us-east-1")
print("uploaded")
else:
print("mycli 1.0")
main()
# lazy.py — defer boto3 until the command that needs it
import sys
def main():
if sys.argv[1:] == ["upload"]:
import boto3 # only loaded on the upload path
boto3.client("s3", region_name="us-east-1")
print("uploaded")
else:
print("mycli 1.0")
main()
Best of 15 cold runs each, same machine as above:
| Scenario | Eager | Lazy (function import) | Lazy (LazyLoader) |
|---|---|---|---|
mycli version (no S3) | 394 ms | 48 ms | 51 ms |
mycli upload (uses S3) | 618 ms | 625 ms | — |
| Saved on the common path | — | 346 ms (8×) | 343 ms |
The shape of that table is the whole argument. On the path that skips S3, deferring the import drops startup from 394 ms to 48 ms. On the path that uses it, lazy and eager are within noise of each other (618 vs 625 ms). You pay the boto3 bill either way; you just pay it later. There’s no downside on the hot path and a large win on the cold one, which is why “defer the import” is close to a free lunch for tools with heavy-but-rarely-used dependencies.
What you can do today, before 3.15
You don’t have to wait for October to claw back most of this. Three techniques work on the Python you already run.
1. Import inside the function that needs it. The oldest trick, and still the most common one in real codebases. Move import boto3 out of module scope and into upload(). That is the lazy.py above, and it is where my 48 ms number came from. The cost: your imports are now scattered through the file instead of grouped at the top, and linters like Ruff will nag about imports not at top level (E402/PLC0415). PEP 810 exists partly to fix exactly this ergonomic wart.
2. importlib.util.LazyLoader for a real module object up front. If you want boto3 to look like a normal top-level name but still defer the work, the standard library already ships the machinery:
import importlib.util, sys
def lazy(name):
spec = importlib.util.find_spec(name)
spec.loader = importlib.util.LazyLoader(spec.loader)
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module) # returns immediately; work is deferred
return module
boto3 = lazy("boto3") # not actually loaded yet
# ... boto3.client(...) triggers the real import here
In my benchmark this landed at 51 ms on the version path, statistically the same as the function-import trick, with the nicer property that boto3 reads like a normal module reference everywhere in the file. The catch is that find_spec still runs at startup and LazyLoader has rough edges with submodule access, so I keep it for a handful of known-heavy deps rather than applying it wholesale.
3. Module-level __getattr__ (PEP 562). For a package’s own __init__.py, you can expose names lazily without importing their backing modules eagerly:
# mypkg/__init__.py
def __getattr__(name):
if name == "heavy":
import mypkg._heavy as heavy
return heavy
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
Now from mypkg import heavy only pulls in _heavy when someone asks for it. This is how a library like scipy keeps its top-level import cheap, and a similar deferral is why import rich only cost me 7 ms while boto3 cost 366.
The honest tradeoff across all three: you move a class of errors from import time to first-use time. A typo’d dependency or a missing native library that used to blow up immediately now blows up the first time the relevant code path runs, which might be in production. PEP 810 inherits this tradeoff too: it is the price of not doing the work, and it is why try/except imports were left eager.
When lazy imports are the wrong call
Lazy is not a free upgrade you flip on everywhere. I would not reach for it when:
- Import side effects matter. Some modules register codecs, warm caches, or patch things at import time. Defer that and you change when those effects happen, occasionally never. Django settings and plugin registries are classic tripwires.
- You want import errors early. For a deployable service, importing everything at boot is a feature: the process fails to start if a dependency is missing, instead of 20 minutes into a request. That is why the global
"all"mode keepstry-wrapped imports eager. - The dependency is on the hot path anyway. If every run touches
boto3, deferring it just moves the same 366 ms later and buys you nothing. Lazy pays off precisely when a heavy import is conditional. - Startup is already fast. If your whole tool imports in 20 ms, there’s nothing here worth the complexity. Measure with
-X importtimebefore optimizing; don’t cargo-cult it.
If you have been tuning Python startup, this fits the broader “Python is getting faster” arc I traced through free-threading in 3.14. For dependency and environment speed rather than import speed, uv is the other big lever, and the whole cluster is collected in the Python tooling guide.
FAQ
What are lazy imports in Python?
Lazy imports defer the real work of importing a module until the first time your code actually uses the imported name. Instead of loading the module when the import statement runs, Python binds a placeholder and only resolves it on first access. PEP 810 adds this as an explicit lazy import statement in Python 3.15; before that, you approximate it with function-level imports or importlib.util.LazyLoader.
How much faster do lazy imports make startup?
It depends entirely on how heavy and how conditional the deferred imports are. In my benchmark, deferring boto3 in a CLI cut the no-S3 path from 394 ms to 48 ms, about 8×. A tool whose imports are already cheap will see almost nothing. Meta reported time-to-first-batch wins up to 40%, and a CPython core dev measured a CLI starting roughly 3× faster (reported). Those are ballpark numbers; yours depend on which imports you defer.
How do I use the lazy import syntax?
Put the soft keyword lazy before an import at module top level: lazy import boto3 or lazy from json import dumps. It is not allowed inside functions, class bodies, or try/except blocks. You can also enable it process-wide with -X lazy_imports=all, PYTHON_LAZY_IMPORTS=all, or sys.set_lazy_imports("all").
Does PEP 810 break existing code?
No. Plain import statements behave exactly as before; nothing goes lazy unless you opt in with the keyword, a per-module __lazy_modules__ list, or a process-wide switch. The main behavior change to watch for when you do opt in is that import errors and import side effects move from import time to first-use time.
Can I get lazy imports before Python 3.15?
Yes, with manual techniques that work on current Python: import inside the function that needs the dependency, wrap heavy modules with importlib.util.LazyLoader, or expose lazy names from a package via a module-level __getattr__ (PEP 562). None are as clean as lazy import, but they deliver most of the startup win today.
Sources
- PEP 810 – Explicit Lazy Imports: the accepted specification covering syntax, control modes, restrictions, and reification semantics
- What’s New in Python 3.15: official release notes covering lazy imports and other 3.15 changes
- Three times faster with lazy imports, by Hugo van Kemenade: a CPython core dev’s real startup measurements (reported figures)
- importlib LazyLoader documentation: the standard-library lazy-loading primitive usable today
- Lazy Imports in Python at Meta: Meta’s engineering write-up on Cinder lazy imports and the reported startup wins
- Python 3.15 lazy imports coverage, BigGo: independent reporting on the Steering Council’s acceptance of PEP 810
Bottom line
Lazy imports are the rare optimization that’s mostly downside-free when you aim them at the right target: a heavy dependency most invocations never touch. Python programmers have been hiding import boto3 inside functions for a decade; PEP 810 just moves that pattern back to the top of the file, gives it a keyword, and adds a switch you can throw for a whole process. My advice for now: run python -X importtime on your slowest CLI, find the one or two imports eating your startup, and defer them with a function import or LazyLoader. When 3.15 lands in October, that becomes a one-line lazy import, and you’ll already know exactly which lines to mark.