Python Lazy Imports: PEP 810 for Faster Startup

You’ve been here before: your CLI tool takes 3.2 seconds to show --help. The code does three HTTP requests and prints a JSON table. Yet every invocation drags in pandas, pydantic, rich, boto3, and a dozen transitive dependencies before executing a single line of your logic. You’ve tried inline imports inside functions, __getattr__-based shim modules, and lazy_loader — each works, each is a band-aid on the import machinery. Python 3.15 ships the real fix. PEP 810, accepted by the Steering Council on November 3, 2025, introduces lazy as a soft keyword that marks individual imports for deferred loading. Python 3.15 beta 1 hit on May 7, 2026, making this the first build you can actually test it on.

This article shows how lazy imports work, where they deliver measurable wins, and the specific edge cases that break when you enable them — with runnable examples and migration patterns for existing code.

How lazy Works Under the Hood

A lazy import creates a placeholder binding in the importing module’s namespace. The target module doesn’t load until the first time code reads that name. After reification, the binding is indistinguishable from an eager import.

lazy import json
lazy from pathlib import Path

def process_file(filepath: str) -> dict:
    with Path(filepath).open() as fh:
        return json.load(fh)

The parser only treats lazy as a keyword when it appears before import or from. A variable named lazy = 1 in your code is unaffected. The keyword is soft, not hard.

Reification happens at first use. When process_file accesses Path, the interpreter resolves the deferred reference, runs pathlib‘s top-level code, binds Path to the resolved class, and continues. When it then accesses json, the same process repeats. Both modules load exactly once; subsequent reads pay no overhead.

import sys

lazy import boto3
lazy import pandas
lazy import requests

def analyze(region: str) -> None:
    client = boto3.client("s3", region_name=region)
    # pandas and requests never load if analyze() doesn't use them
    for bucket in client.list_buckets()["Buckets"]:
        print(bucket["Name"])

def visualize(data: pandas.DataFrame) -> None:
    # pandas loads only here, when this function is first called
    data.plot()
    # requests never loads if this function doesn't call external APIs

In this example, running analyze("us-west-2") loads only boto3. pandas and requests stay unloaded, saving their combined startup cost.

The lazy keyword only applies at module level. It cannot appear inside function bodies, class bodies, or try blocks. This restriction ensures the parser can statically verify the placement. Star imports (lazy from foo import *) are also rejected — the import would have to load the module to discover available names anyway, defeating the purpose.

Three Runtime Modes

The lazy keyword controls individual imports. A separate runtime mode controls how the interpreter treats all imports:

Mode Behavior
`normal` (default) Only imports prefixed with `lazy` defer. Every other import is eager, identical to Python 3.14 and earlier.
`all` Every import is treated as lazy, except those explicitly opted out of. Useful for measuring the upper bound on startup gains, less practical for production.
`none` Forces every import eager, ignoring `lazy` keywords. Useful for debugging issues caused by deferred loading.

Set the mode from the command line, environment variable, or programmatically:

python -X lazy_imports=all script.py
PYTHON_LAZY_IMPORTS=all python script.py
import sys

sys.set_lazy_imports("all")

For libraries that want to opt their imports into lazy behavior without changing their syntax (which wouldn’t parse on Python 3.14), PEP 810 provides a migration shim: declare a __lazy_modules__ list at module top level. On Python 3.15+, imports that name a module in that list defer. On older Pythons, they run eagerly.

# In a library's __init__.py that supports 3.10+
try:
    __lazy_modules__ = [
        "numpy",
        "scipy",
        "pandas",
    ]
except NameError:
    pass  # Python < 3.15 — ignore the list

import numpy
import scipy
import pandas  # All three defer on 3.15+, load eagerly on 3.10-3.14

This approach gives libraries a wide supported-Python range without duplicating their dependency declarations.

Where Lazy Imports Actually Help

Lazy imports speed up startup, not steady-state execution. Whether that matters depends on the shape of your program.

Command-line tools are the primary win. They run for seconds or less, so every millisecond of import overhead is noticeable. Hugo van Kemenade measured pypistats --help dropping from 104 ms to 36 ms — a 2.9x improvement — using the all runtime mode. The PEP itself cites 50–70% startup reduction for real-world CLIs.

#!/usr/bin/env python3
"""my_tool – a CLI that benefits from lazy imports."""

from __future__ import annotations

import argparse
import sys

# Core stdlib — fast, always eager
# Heavy deps — lazy, only loaded on demand

lazy import pandas
lazy import numpy
lazy import rich.console

def main() -> int:
    parser = argparse.ArgumentParser(description="Data analysis CLI")
    parser.add_argument("input", help="CSV file to analyze")
    parser.add_argument("--verbose", action="store_true")
    args = parser.parse_args()

    # pandas only loads when this branch executes
    df = pandas.read_csv(args.input)

    if args.verbose:
        # rich only loads for verbose output
        console = rich.console.Console()
        console.print(f"Rows: {len(df)}")

    print(df.describe())
    return 0

if __name__ == "__main__":
    sys.exit(main())

my_tool --help executes without loading pandas, numpy, or rich. A user scanning available commands pays the cost of the standard library only. The heavy dependencies load only when an actual data pipeline executes.

Long-running servers and workers see no startup win after the first request. A web service runs for hours, pays its import cost once at boot, and amortizes it over millions of requests. Lazy imports change when the cost is paid (boot vs. first request) but not the steady-state throughput.

REPLs and notebooks see no benefit either. Imports happen interactively, one at a time, in response to a human. The startup time users notice is the interpreter’s own boot, not the imports they type later.

Annotation-Only Imports Without TYPE_CHECKING

One of the most underappreciated benefits of lazy imports is eliminating if TYPE_CHECKING: guards for annotation-only dependencies.

Before Python 3.15, the common pattern looks like this:

from __future__ import annotations

from collections.abc import Sequence
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from mypy_extensions import TypedDict

    class Config(TypedDict):
        database_url: str
        max_retries: int

Every runtime execution of this module processes the TYPE_CHECKING guard (trivial overhead) and loads the __future__ annotations feature. More importantly, mypy_extensions still appears in your pyproject.toml dependencies, even though no runtime code uses it.

With lazy imports:

from __future__ import annotations

from collections.abc import Sequence

lazy from mypy_extensions import TypedDict

class Config(TypedDict):
    database_url: str
    max_retries: int

The type annotation still works — type checkers resolve TypedDict statically regardless of runtime behavior. No TYPE_CHECKING guard, no conditional imports, no dependency bloat for annotation-only packages. This is cleaner, more explicit, and directly communicates the import’s purpose.

Common Pitfalls and How to Fix Them

Lazy imports change when a module’s top-level code runs. Anything that depends on top-level code executing at the import line — rather than at first use — can break.

Pitfall 1: Decorator-Based Plugin Registration

Many framework plugins register themselves with a central registry at import time:

# plugins/feedback.py
from registry import register

@register("feedback")
def submit_feedback(data: dict) -> str:
    return f"Feedback: {data['text']}"

When this module is imported lazily, the decorator doesn’t execute until the feedback name is first accessed. If your framework iterates registered plugins at startup, it won’t find them.

# BEFORE: plugin discovery fails with lazy imports
# import plugins.feedback  # lazy — decorator hasn't run yet
# print(registry.list())  # empty!

# AFTER: explicit registration function
def register_feedback() -> None:
    from registry import register

    @register("feedback")
    def submit_feedback(data: dict) -> str:
        return f"Feedback: {data['text']}"

# Call it from the plugin loader
register_feedback()

The fix moves the side effect into an explicit function that the consumer calls, rather than relying on the import line as a coincidental trigger.

Pitfall 2: Errors Move from Import-Time to First-Use

An ImportError or ModuleNotFoundError that used to fire at the import line now fires at the first attribute access. Stack traces point at the use site rather than the import line.

lazy from nonexistent_module import something

def run() -> None:
    # ImportErrors that used to be caught at import time
    # now surface at first use — often deeper in the call stack
    print(something)  # ModuleNotFoundError: No module named 'nonexistent_module'

The error still surfaces — it’s just delayed. The fix is to verify critical dependencies early, before they can fail unpredictably:

import sys

lazy from nonexistent_module import something

def _ensure_deps() -> None:
    """Verify dependencies on first call."""
    global something  # force reification
    from nonexistent_module import something  # eager, for error reporting

def run() -> None:
    _ensure_deps()
    print(something)  # now safe

Pitfall 3: Star Imports and Module Introspection

Star imports are rejected by the parser (lazy from foo import * does not compile). But related introspection patterns can produce surprising results:

lazy import os

# This works — triggers reification
print(os.getcwd())

# This also works — triggers reification
print(dir(os))

# But checking __all__ before first access returns nothing
print(os.__all__)  # AttributeError: module 'os' has no attribute '__all__'
                    # Reification happens, then access succeeds

The first attribute access triggers reification. If you access __all__ as the first thing, the module loads and __all__ is available — there’s no error. But code that checks for hasattr(module, "__all__") before any other attribute access would see the lazy placeholder, not the loaded module. In practice, the lazy object forwards hasattr checks to the underlying module once reification occurs, so this is rarely a real problem.

Pitfall 4: Circular Imports Don’t Auto-Fix

Lazy imports are not a magic bullet for circular dependencies. If two modules circularly depend on each other and the cycle is exercised during initialization, marking the imports lazy doesn’t help:

# a.py — lazy import won't help if cycle fires at init
import sys

lazy import b

def run_a() -> None:
    b.do_something()  # reifies b
    # If b.do_something() accesses a.some_function(), we're still circular

Lazy imports help if the cycle is only exercised inside functions called later, when the full call graph has already been established. They don’t solve true initialization-time circular dependencies.

Comparing PEP 810 to Third-Party Solutions

Several libraries have offered lazy imports for years. PEP 810 is narrower than some and broader than others:

lazy_loader (used by NumPy, SciPy, scikit-image) defers submodule imports through __getattr__ plumbing. It runs on Python 3.7+ and ships as a wheel. The cost is boilerplate in every package that uses it, plus a bespoke IDE and static-analysis story.

Mercurial’s demandimport rewrites the import system at startup to make all imports lazy. It predates lazy_loader by years and is the canonical example of how Mercurial keeps hg startup fast despite its massive dependency tree.

PEP 810 is narrower than demandimport — there’s no implicit global lazy mode by default, and the all runtime flag must be explicitly opted into. But it’s broader than lazy_loader — it has parser-level support, zero boilerplate, and IDEs, type checkers, and linters can reason about it directly. The trade-off is the Python version floor: lazy_loader covers libraries supporting 3.7+, while lazy is 3.15-only without the __lazy_modules__ shim.

Migration Strategy

Start with your most performance-sensitive entry points. For CLIs, that’s the top-level script. For web frameworks, that’s the WSGI/ASGI entry point or the development server hot-reloader.

1. Audit imports — Identify which modules are loaded but rarely used in your common execution paths. CLI tools typically have subcommand modules that load even when the user runs --help.

2. Add lazy selectively — Mark the heaviest, least-frequently-used imports as lazy. Keep frequently-used or stdlib imports eager.

3. Test the all mode — Set PYTHON_LAZY_IMPORTS=all in development to measure the upper bound on startup improvements and surface any lazy-specific bugs.

4. Verify plugin discovery — If your codebase uses import-time side effects (plugin registration, decorator-based hooks), refactor to explicit registration functions as shown above.

5. Pin your minimum version — Once you’ve adopted lazy, add python_requires = ">=3.15" to your pyproject.toml and remove any __lazy_modules__ shim.

Wrap-up

PEP 810 gives Python a first-class, parser-level mechanism for deferring module imports. It’s not a universal optimization — long-running processes and interactive sessions see little benefit — but for command-line tools and any application where startup latency matters, it’s a 50–70% improvement with zero runtime overhead after the first use.

The explicit lazy keyword keeps the behavior local and predictable: each import is an isolated decision, not a global shift in semantics. After reification, lazy bindings are indistinguishable from eager ones. The only changes are when modules load and where errors surface.

Start by marking your heaviest, least-frequently-used imports as lazy. Test with PYTHON_LAZY_IMPORTS=all in development. Refactor any import-time side effects into explicit functions. Your users will notice the difference.

References

1. PEP 810 – Explicit lazy imports 2. PEP 810 performance section – startup time benchmarks 2. PEP 810 Discourse discussion thread 3. Python 3.15 beta 1 announcement 4. lazy-loader on GitHub (Scientific Python) 5. lazy-imports performance blog post by Hugo van Kemenade 6. SPEC 1 – Lazy Submodule Loading (Scientific Python) 7. PEP 690 – Implicit lazy module loading (rejected) 8. importlib.util.LazyLoader documentation

No comments yet. Be the first to leave a comment!

Leave a Comment

Your email address will not be published. Required fields are marked *