Python 3.15 Lazy Imports: Faster Startup Times and the Design Behind PEP 810
PEP 810 improves import-time control without making every Python import lazy by default. Here's why.
Python imports are usually invisible infrastructure that we don't need to worry about.
But they become visible when startup time starts to matter.
If you have ever waited for a command-line tool to show --help, a test runner to collect tests, or a web app to reload after a small change, you have probably paid the import-time tax.
Python 3.15 adds an official solution: explicit lazy imports.
In short, PEP 810 introduces a new lazy soft keyword:
lazy import json
lazy from pathlib import Path
print("Starting up...") # json and pathlib are not loaded yet
data = json.loads('{"name": "Yang"}') # json loads here
path = Path(".") # pathlib loads hereThe idea is simple: declare the dependency at the module level, but delay loading it until the imported name is first used.
In this article, let's look at the design of Python 3.15's lazy imports, the current workarounds they replace, why the feature is explicit rather than default, and how we can adopt it in real Python projects.
The Import Cost Hidden in Startup
An import statement does more than bind a name.
When Python imports a module, it may need to find the file, read it, compile it to bytecode, execute the module-level code, create functions and classes, import subdependencies, and register the module in sys.modules.
For a tiny script, this is fine.
For a mature application, the import graph can become large and expensive.
For example:
# app.py
import argparse
import pandas as pd
import matplotlib.pyplot as plt
import boto3
from myapp.reports import build_pdf
from myapp.server import run_server
def main() -> None:
...If the user runs:
python -m myapp --helpShe probably does not need pandas, matplotlib, boto3, or a PDF generator.
But normal imports are eager. Python loads them anyway.
The Python 3.15 What's New documentation describes this exact problem:
Large dependency trees can make startup take seconds, even when much of the imported code is never used in a particular run.
How Python Developers Solve This Today
The classic workaround is local import.
We move expensive imports into the function that actually needs them:
def export_report(rows: list[dict[str, object]]) -> None:
import pandas as pd
from myapp.reports import build_pdf
df = pd.DataFrame(rows)
build_pdf(df)This works.
If export_report() is never called, pandas and build_pdf are never imported.
The tradeoff is that imports are now scattered around the codebase:
def export_report(rows):
import pandas as pd
return pd.DataFrame(rows)
def send_invoice(customer_id):
import stripe
return stripe.Customer.retrieve(customer_id)
def plot_metrics(values):
import matplotlib.pyplot as plt
return plt.plot(values)It works, but it makes Python programs less elegant than we expect.
Python 3.15 lazy imports keep the import at the top while preserving the performance benefit:
lazy import pandas as pd
lazy from myapp.reports import build_pdf
def export_report(rows: list[dict[str, object]]) -> None:
df = pd.DataFrame(rows) # pandas loads here
build_pdf(df) # myapp.reports loads hereThis is the value of the feature.
It makes the performance optimization visible without damaging the code structure.
Why Existing Lazy-Loading Tools Are Still Awkward
Python already has tools for dynamic and lazy importing.
For example, we can use importlib.import_module():
import importlib
def load_backend(name: str):
return importlib.import_module(f"myapp.backends.{name}")This is great for plugin systems, but for ordinary dependencies.
If all we want is "import this module later", importlib.import_module() turns a simple language-level statement into a string-based runtime operation. Static analysis, refactoring, and readability become weaker.
Some libraries use module-level __getattr__ tricks or packages such as lazy_loader. The scientific Python ecosystem even has SPEC 1 for lazy loading submodules and functions.
Those solutions are useful for certain scenarios, but Python still needed an elegant, built-in, and readable way for application code to say:
"This import is real, but please load it only when I use it."
That is exactly what lazy import of Python 3.15 does.
What Python 3.15 Changes
The new syntax is deliberately plain:
lazy import heavy_module
lazy import pandas as pd
lazy from rich.console import ConsoleThe keyword is soft, which means lazy only has special meaning before an import or from statement. In other places, it can still be an ordinary name.
According to the Python language reference, the module is not loaded immediately. Python creates a lazy proxy object and binds it to the imported name. The actual import happens on first use.
Let's see the idea:
import sys
lazy import json
print("json" in sys.modules)
# False
payload = json.dumps({"author": "Yang"})
print("json" in sys.modules)
# TrueOne subtle detail is that lazy imports defer module loading, not partial module loading.
For example:
lazy from heavy_module import build_report, send_email
build_report() # heavy_module loads hereWhen build_report is first accessed, Python imports the entire heavy_module, executes its top-level code, and resolves the requested name.
It does not load only the code that defines build_report. So if you later use send_email, Python does not import the module again because it is already loaded.
A Strong Use Case: Command-Line Applications
Command-line tools are one of the best use cases.
Imagine this structure:
import argparse
lazy import pandas as pd
lazy from myapp.reports import export_pdf
lazy from myapp.server import serve
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser()
parser.add_argument("command", choices=["serve", "export"])
args = parser.parse_args(argv)
if args.command == "serve":
serve()
elif args.command == "export":
df = pd.DataFrame(load_rows())
export_pdf(df)If the user asks for help, the optional heavy modules do not load.
If the user runs serve, the reporting stack does not load.
If the user runs export, the server stack does not load.
This is the kind of optimization users can feel. A command that starts in 200 ms instead of 1.5 seconds feels more efficient.
Type-Only Imports Become Cleaner
Type hints often create imports that do not matter at runtime.
Today, many projects use the TYPE_CHECKING trick as follows:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
def summarize(items: "Sequence[str]") -> "Mapping[str, int]":
...This pattern is useful, but it adds noise.
With Python 3.15 lazy imports, we can often write:
lazy from collections.abc import Mapping, Sequence
def summarize(items: Sequence[str]) -> Mapping[str, int]:
...As PEP 810 explains, lazy imports can remove the runtime cost of annotation-only imports without hiding them behind if TYPE_CHECKING:.
Why Python Should Not Make Every Import Lazy by Default
When I first saw this new feature, one question immediately came to my mind:
If lazy imports improve startup, why not make import lazy everywhere?That sounds like the natural next update for Python 3.16?
But after thinking about how imports actually work in Python, I realized the answer is not that simple:
Imports are not just dependency declarations. They can also execute code.
This is the central compatibility concern.
Many Python modules do useful or dangerous things at import time:
# plugins/pdf_exporter.py
from myapp.plugins import register
@register("pdf")
class PDFExporter:
...Somewhere else:
import plugins.pdf_exporter
# The import registered PDFExporter as a side effect.If that import becomes lazy, the plugin is not registered until somebody first touches plugins.pdf_exporter.
That can break the application.
Another example:
# app_logging.py
import logging
logging.basicConfig(level=logging.INFO)If a project relies on import app_logging to configure logging, making the import lazy changes when logging is configured.
The bug may appear far away from the import statement.
There are potential other concerns:
- Import errors happen later, at first use.
- The import order can change.
sys.modulescontents can differ before first use.- Some introspection may see lazy proxy objects.
- The first access may happen in a different thread.
Global Lazy Imports: A Tool for Deployment and Performance Tuning
Python 3.15 also includes broader controls:
python -X lazy_imports=all -m myappor:
PYTHON_LAZY_IMPORTS=all python -m myappThe modes are:
normal: only imports explicitly marked withlazyare lazy;all: most eligible module-level imports are treated as lazy imports automatically;none: imports are eager, even if marked lazy.
There is also a runtime API:
import sys
sys.set_lazy_imports("all")
print(sys.get_lazy_imports())And for advanced use cases, a filter can decide which imports should stay lazy:
import sys
def lazy_filter(importer: str | None, name: str, fromlist: tuple[str, ...] | None) -> bool:
side_effect_modules = {"myapp.logging_setup", "myapp.plugin_registry"}
return name not in side_effect_modules
sys.set_lazy_imports_filter(lazy_filter)
sys.set_lazy_imports("all")In practice, the safest approach is to use explicit lazy imports in application code and reserve global lazy-import modes for experimentation, deployment environments, and performance tuning after careful testing.
The reason is simple: applying lazy imports globally changes the behavior of the whole codebase. Import-time side effects, registration mechanisms, logging setup, and other initialization logic may suddenly happen later than expected.
A Conservative Migration Plan
If you are going to migrate your project to Python 3.15, my recommendation is to do it in a conservative way: measure first and change second.
- Firstly, run Python's built-in import-time profiler:
python -X importtime -m myapp --help- Then, based on the results, look for imports that are both slow and unnecessary for common startup paths, such as the following:
import time: self [us] | cumulative | imported package
import time: 120000 | 450000 | pandas
import time: 70000 | 300000 | matplotlib
import time: 30000 | 180000 | boto3- Make the necessary code change now:
lazy import pandas as pd
lazy import matplotlib.pyplot as plt
lazy import boto3- Finally, measure again to see the improvement.