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.

Share
a lazy cat
Image from Wallhaven

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 here

The 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 --help

She 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 here

This 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 Console

The 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)
# True

One 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 here

When 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.modules contents 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 myapp

or:

PYTHON_LAZY_IMPORTS=all python -m myapp

The modes are:

  • normal: only imports explicitly marked with lazy are 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.

References