Python 3.15 Lazy Imports: Measured Startup Savings on Real Libraries

Sanchez Kim
Sanchez Kim
AI Engineer · · 14 min read

Python 3.15 adds lazy import (PEP 810). Measured on requests, rich, numpy, pandas and a small CLI: unused imports drop to near zero, used ones cost the same, and the global flag broke pandas.

#python#python 3.15#lazy imports#pep 810#startup time#performance#cli
Python 3.15 Lazy Imports: Measured Startup Savings on Real Libraries

Python 3.15 adds a lazy keyword for imports. Write lazy import pandas and Python binds the name pandas but does not load the module until your code first uses it. PEP 810 defines the feature, and What's New in 3.15 lists it first under New features.

I measured it on my Mac with four real libraries and a small command-line tool. An import you never use drops to roughly zero cost: import pandas added 151 ms to a 13 ms empty script, and lazy import pandas added nothing measurable. An import you do use costs the same as before. The work happens at first use instead of at the top of the file. The place it pays off is a CLI whose --help never touches its heavy dependencies. Mine went from 75 ms to 26 ms.

All numbers here were measured on 2026-10-13 on Python 3.15.0, the final release that came out on 2026-10-09 (PEP 790).

What a lazy import is

The language reference describes lazy as a soft keyword that "only has special meaning when it appears immediately before an import or from statement". Instead of loading the module, Python binds the name to a proxy object. In the reference's words: "The actual module is loaded on first use of that name."

lazy import json
lazy from pathlib import Path

print("starting")          # json and pathlib are not loaded yet
data = json.loads("{}")    # json loads here

I checked what the name holds before and after first use:

before: lazy_import | json in sys.modules: False | isinstance LazyImportType: True
after:  module | json in sys.modules: True

The proxy type is public as types.LazyImportType. A lazy from module import name still runs the whole module on first use, because Python can't execute part of a file. The PEP's FAQ calls this "identical to eager from module import Class behavior" — it just happens later.

There are restrictions, and all of them are compile-time errors. These are the messages 3.15.0 printed:

Where Result on 3.15.0
inside a function SyntaxError: lazy import not allowed inside functions
inside a class body SyntaxError: lazy import not allowed inside classes
inside try/except SyntaxError: lazy import not allowed inside try/except blocks
lazy from json import * SyntaxError: lazy from ... import * is not allowed

Because the keyword is soft, existing code that uses lazy as a variable name still runs. lazy = 3 worked fine.

Three ways to turn it on

The keyword, per import, as shown above. A file that uses it will not even parse on older versions. On 3.14.6 my lazy CLI stopped at SyntaxError: invalid syntax, pointing at lazy from.

__lazy_modules__, for code that must also run on older Pythons. Define a module-level list of fully qualified module names, and regular import statements for those names become lazy on 3.15. On 3.14 it is just an unused variable (reference). I measured below that it gives the same result as the keyword.

A global mode, without editing code: python -X lazy_imports=all or PYTHON_LAZY_IMPORTS=all. The command-line docs list two values: all makes all imports lazy by default, and normal, the default, respects the keyword. At runtime, sys.set_lazy_imports() and sys.get_lazy_imports() set and read the mode, and sys.set_lazy_imports_filter() takes a callable that decides per import (sys docs).

One detail differs between sources. The PEP text also describes a third mode, none, which forces every import to be eager. The 3.15 docs list only all and normal, and 3.15.0 rejects none before the interpreter starts:

$ python -X lazy_imports=none mode.py
Fatal Python error: config_init_lazy_imports: -X lazy_imports: invalid value; expected 'all' or 'normal'

sys.set_lazy_imports("none") raises ValueError: mode must be 'normal' or 'all'. Go by the docs, not the PEP, on this one.

Measured: what an unused import costs

Each bar is a fresh interpreter running a one-line script, import X or lazy import X, that never touches the module. The time is wall clock from spawn to exit, so it includes Python's own startup. An empty script took 12.8 ms.

Horizontal bar chart of wall-clock startup time, median of 30 runs with p10 to p90 range, Python 3.15.0 on Apple M5. requests: eager 58 ms, lazy 13 ms. rich Console: eager 26 ms, lazy 13 ms. numpy: eager 40 ms, lazy 13 ms. pandas: eager 164 ms, lazy 13 ms. CLI --help with argparse, rich and requests: eager 75 ms, lazy 26 ms. An empty script takes 13 ms.

Median of 30 runs per case, bars show p10–p90. Python 3.15.0 on an Apple M5 MacBook Pro.

Library (version) import X lazy import X Cost of the eager import above an empty script
requests 2.34.2 58.2 ms 12.9 ms 45.4 ms
rich 15.0.0 (from rich.console import Console) 25.8 ms 12.8 ms 13.0 ms
numpy 2.5.3 40.2 ms 12.7 ms 27.4 ms
pandas 3.0.6 164.2 ms 12.8 ms 151.4 ms

Every lazy case finished within 0.1 ms of the empty script, inside the run-to-run noise. Creating the proxy is effectively free. Running -X lazy_imports=all on the plain import X scripts gave the same result, between 12.7 and 12.9 ms.

python -X importtime shows where the eager time goes. It is spread across many modules, with no single culprit. import pandas loaded 550 modules beyond what an empty interpreter loads, and the most expensive single module, pandas._libs.join, took about 2 ms of its own time. import requests loaded 219 modules, led by urllib3.util.url, _hashlib and charset_normalizer.cd. That spread is why lazy imports help: no single slow module is worth fixing, but the whole tree can be skipped.

The practical win: a CLI's --help

Command-line tools are where this matters. You type --help or a mistyped flag, and the tool loads its HTTP and rendering stacks just to print usage text. I wrote a small tool, peek, that shows a JSON list as a table, from a URL (requests) or a local file, and renders it with rich. The lazy version changes three lines:

import argparse
import json
lazy from rich.console import Console
lazy from rich.table import Table
lazy import requests
peek command eager lazy keyword __lazy_modules__ -X lazy_imports=all
--help 75.2 ms 25.6 ms 25.5 ms 24.9 ms
table data.json (uses rich, not requests) 71.5 ms 34.0 ms 33.9 ms 30.4 ms

--help got 66% faster. The table command still needs rich, so it pays for rich and skips only requests, and it got 52% faster. -X importtime confirms the mechanism: with eager imports, peek --help loaded 45 rich modules and 63 modules from requests and its dependencies. With lazy imports it loaded none of them.

The __lazy_modules__ version matched the keyword within a tenth of a millisecond, and the same file ran unchanged on 3.14.6, where it took 76.3 ms because 3.14 ignores the variable. If you maintain a CLI that supports several Python versions, that is the low-risk way to start.

The catch: the cost moves, it does not disappear

When the module is used, the total is the same. I added one real call after each import, such as np.arange(1000).sum() or building a small DataFrame:

Import, then use it eager lazy difference
requests 58.1 ms 57.5 ms −0.6 ms
rich 27.3 ms 27.5 ms +0.2 ms
numpy 39.9 ms 40.1 ms +0.2 ms
pandas 167.1 ms 170.2 ms +3.1 ms

All four differences are inside the noise: pandas' own p10–p90 ranges span 19 to 39 ms, and its lazy case moved by 6.8 ms between my two runs. Lazy imports do not make a library load faster. They move the load to the first line that needs it, and if every run reaches that line, nothing is saved.

Errors move too. A missing module no longer fails at the import line. This script printed its first line and failed only when main() used the module (paths shortened to file names):

1. script started; the lazy import did not fail
2. about to use not_installed_pkg
Traceback (most recent call last):
  File "missing.py", line 1, in <module>
    lazy import not_installed_pkg
ImportError: lazy import of 'not_installed_pkg' raised an exception during resolution

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "missing.py", line 11, in <module>
    main()
    ~~~~^^
  File "missing.py", line 8, in main
    not_installed_pkg.run()
    ^^^^^^^^^^^^^^^^^
ModuleNotFoundError: No module named 'not_installed_pkg'

The traceback names both the import line and the line of first use, which helps. But a broken dependency now surfaces in the middle of a run, possibly after the program has already done work, instead of at startup.

Import-time side effects are deferred. Some packages register plugins, handlers or codecs simply by being imported. I made a module that appends itself to a registry on import:

eager: registered plugins = ['csv']
lazy:  registered plugins = []

With lazy import plugin_csv, the registration never happened, because nothing touched plugin_csv. Plain import lines for side effects should stay eager. The PEP's FAQ describes the same deferral and recommends explicit initialization functions instead.

The global flag: faster, and it broke pandas

-X lazy_imports=all also makes imports inside the libraries lazy, so a library that is used can still skip its own unused submodules. With the same import-and-use scripts as above:

Import, then use it default -X lazy_imports=all
requests 58.1 ms 37.9 ms
numpy 39.9 ms 33.2 ms
rich 27.3 ms 25.6 ms
pandas 167.1 ms failed

pandas failed on every attempt. Building a DataFrame under the global flag ended in a chain of errors from pandas' compiled extensions:

AttributeError: module 'pandas' has no attribute '_pandas_datetime_CAPI'
SystemError: <built-in function __import__> returned a result with an exception set
ImportError: numpy._core.multiarray failed to import

A filter that keeps pandas eager fixed it. With sys.set_lazy_imports_filter() returning False for pandas and pandas.*, the same script printed the right answer under -X lazy_imports=all. The sys docs say library authors "should generally not use" set_lazy_imports(), and my result shows why the global mode is an application-level decision you test per dependency.

Did 3.15 itself start faster than 3.14?

Maybe slightly, but my numbers can't settle it. I ran the same eager scripts on a uv-managed 3.14.6, right after the 3.15.0 cases in each run. In the first run, an empty script took 12.9 ms on both versions. requests, numpy and pandas were between 1.6 and 3.4 ms faster on 3.15.0, which is within what I saw between two complete runs on the same version: pandas' lazy-then-use case alone measured 163 ms in one run and 170 ms in the other.

The second run is less useful for this comparison. The load average was higher when it started (4.6 against 2.6), and every 3.14.6 number came out 0.7 to 13.0 ms slower than in the first run, while the 3.15.0 numbers, measured first, moved by at most 1.1 ms outside pandas. That run shows 3.15.0 ahead by 4 to 14 ms on the libraries, and I don't count it as evidence.

Two cases are worth a closer look. rich was faster on 3.15 in both runs, by 3.3 and 4.2 ms. My CLI's eager --help had no consistent direction: 2.6 ms slower on 3.15.0 in the first run (72.6 ms on 3.14.6 against 75.3 ms) and 1.7 ms faster in the second. On the release candidate it had been 3–4 ms slower, so I would not read it as a regression, or as a fix. For rich, the difference has a visible cause. On 3.14, importing it also pulled in inspect, dis, tokenize, copy and seven other standard library modules that 3.15 does not load. Part of the reason is that the 3.15 standard library imports less up front. dataclasses no longer imports inspect at the top and uses lazy import for copy and re. site, argparse, typing and collections also contain lazy imports in 3.15.0.

When to use it, and when not to

Use it for:

  • Imports that only some code paths need: subcommands, export formats, optional backends, rarely used error handlers.
  • CLIs, where --help and argument errors should be instant.

Keep these eager:

  • Imports you rely on for side effects such as registration or monkey-patching.
  • Dependencies that should fail at startup if they are missing, so a misconfigured deployment fails early and not halfway through a job.
  • Anything a hot loop touches first. The first touch pays the full import cost at that point.

And treat -X lazy_imports=all as an experiment you run against your test suite, not a default.

How I measured

  • Machine: Apple M5 MacBook Pro, 32 GB, macOS 26.6.2, on AC power. Other applications were open; the load average was 2.6 when the first run started and 4.6 when the second started.
  • Interpreters: CPython 3.15.0 (main, Oct 9 2026) [Clang 22.1.3] and 3.14.6, both uv-managed builds, each in a throwaway venv with identical pinned packages installed from wheels only.
  • Timing: each case is a fresh process timed from spawn to exit. 3 warm-up passes over all cases, then 30 rounds in a shuffled order so drift spreads across cases. I report the median with the 10th–90th percentile range.
  • Run-to-run check: I ran the whole suite twice, back to back. On 3.15.0, medians moved by −0.2 to +1.1 ms between runs, except pandas, which moved by up to 6.8 ms. On 3.14.6, measured after 3.15.0 in each run, every median moved upward in the second run, by 0.7 to 13.0 ms. This post uses the second run for the 3.15.0 numbers.
  • Import breakdown: python -X importtime, 7 runs per library, median per module.

Limitations

  • One machine, one day. These are 3.15.0 numbers on an M5. Linux, Windows and slower disks will give different absolute numbers, and so does background load. An earlier pass on 3.15.0rc3, with the load average around 2, measured every case faster than here (the empty script by 1 ms, pandas by 13 ms), and I can't separate the lower load from the release change. The CLI savings were the same in both passes, 66% for --help and 52% for table. The ratio between eager and lazy is what I expect to carry over.
  • Wall-clock startup only. I did not measure memory, long-running performance after reification, or threads.
  • Four libraries. I picked ones with Python 3.15 wheels on PyPI. When I checked on 2026-10-13, numpy 2.5.3 (the version I pinned; 2.5.4 is now out) and pandas 3.0.6 had macOS arm64 wheels for 3.15. torch 2.14.1 still had no 3.15 wheels on PyPI. pydantic is a moving case. When I set up the lab on 2026-10-04, pydantic 2.13.5 pinned pydantic-core==2.46.5, which had no 3.15 wheel, even though a newer pydantic-core, 2.49.0, already shipped one for macOS arm64. My first install attempt quietly compiled it from source with Rust, so I deleted that environment, installed with --only-binary :all: from then on, and left pydantic out. By 2026-10-13, pydantic 2.14.1 pins pydantic-core==2.50.1, which does ship 3.15 wheels, so a plain install now works. I kept the package set unchanged so the runs stay comparable.
  • Small scripts. A large application has deeper import trees and more side effects, so both the savings and the breakage could be larger than here.

References

Related Posts