The Evolution of Lazy Imports in Python


From Manual Workarounds to a First-Class Language Feature — June 2026

The Evolution of Lazy Imports in Python

"The best import is the one that never runs."

June 2026 · Giacomo Saccaggi


The Problem: Import-Time Tax

Every import statement in Python is an imperative action. It finds a module, executes all its top-level code, and binds the result to a name. For heavy libraries, this means your CLI tool pays seconds of startup cost just to print --help.

# This script takes 2+ seconds to start, even for --help
import numpy as np          # ~150ms
import pandas as pd         # ~300ms
import tensorflow as tf     # ~2000ms

def main(args):
    if args.command == "train":
        # Only THIS path needs tf
        tf.keras.models.load_model(args.path)
    elif args.command == "stats":
        # Only THIS path needs pandas
        pd.read_csv(args.file).describe()

The user running mycli stats data.csv pays for TensorFlow loading even though it's never used. This is the import-time tax.

Benchmark: Quantifying the Cost

We created a simulated "heavy" module (150ms initialization + 10MB allocation) and measured three strategies:

StrategyModule UsedModule NOT Used
Time (ms)Memory (MB)Time (ms)Memory (MB)
Eager (top-level)21010.6834110.68
Manual Lazy (inline)21310.681770.40
Smart Lazy (LazyLoader)20910.691760.45

Key finding: When the module is NOT used, lazy imports save 164ms (48%) and 10.2MB (96%). When it IS used, all strategies converge — you pay the same cost regardless.


The Evolution: Python Version by Version

Python 3.4–3.6 (2014–2016): The Dark Ages

The only options were inline imports inside functions or manually wiring importlib.util.LazyLoader:

# Option A: Inline import (works on any Python version)
def process_data(path):
    import pandas as pd   # Only loaded when function is called
    return pd.read_csv(path)

# Option B: LazyLoader (Python 3.4+)
import importlib.util
import sys

def _lazy_import(name):
    spec = importlib.util.find_spec(name)
    loader = importlib.util.LazyLoader(spec.loader)
    spec.loader = loader
    module = importlib.util.module_from_spec(spec)
    sys.modules[name] = module
    loader.exec_module(module)  # Does NOT run module code yet!
    return module

# Module object exists, but code runs only on first attribute access
pd = _lazy_import("pandas")

Problems: 15+ lines of boilerplate per module. LazyLoader doesn't support from X import Y. Invisible to type checkers and IDEs. No standard pattern — every project invented its own.

Python 3.7 (2018): PEP 562 — The Breakthrough

PEP 562 — Module __getattr__ and __dir__ ACCEPTED

Introduced the ability to define __getattr__ at the module level, intercepting attribute access on the module object itself. This enabled the first clean lazy loading pattern.

# mypackage/__init__.py
# Submodules are loaded ONLY when accessed: mypackage.heavy_sub

def __getattr__(name):
    if name == "heavy_sub":
        from mypackage import heavy_sub
        globals()["heavy_sub"] = heavy_sub  # Cache it
        return heavy_sub
    raise AttributeError(f"module has no attribute {name!r}")

def __dir__():
    return ["heavy_sub", "light_func", "utils"]

Impact: Adopted by NumPy, SciPy, and the Scientific Python community as SPEC 1. The lazy_loader PyPI package formalized the pattern. First time lazy imports had a standard approach.

Python 3.8–3.10 (2019–2021): Incremental Improvements

No new lazy import mechanisms, but related improvements:

VersionChangeImpact on Imports
3.8importlib.metadata addedLighter alternative to pkg_resources (which was notoriously slow to import)
3.9importlib.resources stabilizedStandard resource loading without heavy I/O at import time
3.10-X importtime refinedBetter profiling to identify which imports to make lazy
# Find your import bottlenecks (Python 3.7+)
$ python -X importtime -c "import your_app" 2>&1 | sort -t'|' -k2 -n | tail -5

# Output:
# import time: self [us] | cumulative |   imported package
# import time:    142857 |     285714 |   tensorflow
# import time:     95238 |     190476 |   pandas
# import time:     47619 |      71429 |   numpy

Python 3.11 (2022): The Faster CPython Revolution

The single biggest performance leap in modern Python. While not adding new lazy mechanisms, it made ALL imports faster:

# The adaptive interpreter specializes hot bytecode
import dis

def multiply(a, b):
    return a * b

# After several calls with floats:
# BINARY_OP 5 (*) → BINARY_OP_MULTIPLY_FLOAT 5 (*)
# This same mechanism will power PEP 810's zero-overhead lazy imports

Python 3.12 (2023): Cleanup and Foundations

Python 3.13 (2024): The stdlib Gets Lighter

The standard library itself underwent an import-time diet:

Meanwhile, PEP 690 had been rejected in late 2022:

PEP 690 — Lazy Imports REJECTED (Dec 2022)

Proposal: Make ALL imports lazy by default, globally, at the interpreter level.

Championed by: Meta (Facebook), based on their Cinder CPython fork that had been running lazy imports in production on Instagram's server infrastructure.

Why rejected:

Python 3.15 (2026): PEP 810 — The Native Solution

PEP 810 — Explicit Lazy Imports ACCEPTED (Nov 2025)

A new lazy soft keyword. Explicit, local, opt-in, zero-overhead after first use. The culmination of 8 years of evolution.

import sys

# The lazy keyword defers loading until first use
lazy import json
lazy from json import dumps, loads

print('json' in sys.modules)   # False — not loaded yet!

# First use triggers "reification" (actual import happens NOW)
result = dumps({"hello": "world"})

print('json' in sys.modules)   # True — loaded on first use

Why PEP 810 succeeded where PEP 690 failed:

Design ChoicePEP 690 (Rejected)PEP 810 (Accepted)
ScopeGlobal — all imports lazy by defaultLocal — only where you write lazy
VisibilityImplicit — no code change neededExplicit — visible at the import site
CascadingRecursive into dependenciesNon-recursive — only affects that import
ImplementationModified dict internalsProxy objects + bytecode specialization
AdoptionAll-or-nothingIncremental, per-import

Zero overhead via adaptive specialization:

lazy import json

def use_json():
    return json.dumps({})

# Bytecode BEFORE first call:
#   LOAD_GLOBAL  0 (json)       ← checks if lazy proxy
#   LOAD_ATTR    2 (dumps)

# Bytecode AFTER 2-3 calls (adaptive interpreter specializes):
#   LOAD_GLOBAL_MODULE  0 (json)  ← direct dict access, ZERO check
#   LOAD_ATTR_MODULE    2 (dumps) ← fast path, same as eager

After reification, the lazy import is indistinguishable from an eager one — same bytecode, same speed, same module object in sys.modules.

Backward compatibility bridge:

# For libraries supporting Python < 3.15
# On 3.15+: these imports become lazy
# On older Python: __lazy_modules__ is simply ignored
__lazy_modules__ = ["numpy", "pandas", "scipy"]

import numpy
import pandas
from scipy import optimize

Summary: The Complete Timeline

YearPythonMechanismBoilerplate
20143.4importlib.util.LazyLoader~15 lines per module
20183.7PEP 562: module __getattr__~8 lines per module
2019–20213.8–3.10Same patterns, better profiling~8 lines
20223.11Faster CPython (all imports faster)~8 lines
2022PEP 690 rejected (implicit global lazy)
2023–20243.12–3.13Stdlib lighter, JIT foundations~8 lines
20263.15PEP 810: lazy import keyword1 keyword

Practical Recommendations

Today (Python 3.7–3.14)

# For CLI tools and applications: inline import
def train_model(data_path):
    import tensorflow as tf   # Only pay the cost here
    model = tf.keras.models.load_model("saved_model")
    ...

# For libraries with optional heavy deps: PEP 562 pattern
# mylib/__init__.py
def __getattr__(name):
    if name == "plotting":
        from mylib import plotting
        globals()["plotting"] = plotting
        return plotting
    raise AttributeError(f"module has no attribute {name!r}")

Tomorrow (Python 3.15+)

# Replace ALL of the above with one keyword:
lazy import tensorflow as tf
lazy import pandas as pd
lazy from scipy.optimize import minimize

# Clean, explicit, zero-overhead, type-checker friendly

When NOT to use lazy imports:


Case Study: scomp-link — From 5.2s to 5ms

scomp-link is an end-to-end ML toolkit with 20+ public classes spanning regression, classification, NLP (BERT), computer vision (CNN), anomaly detection, explainability, and time series. Version 1.1.4 imported everything eagerly in __init__.py.

The Damage: v1.1.4 (All Eager)

MetricValue
import scomp_link5,192 ms
scomp-link --help4,530 ms
Memory at import152.2 MB

A user running from scomp_link import DataQualityReport (which only needs pandas) paid for torch, transformers, shap, sklearn, and every model in the toolkit. The full import chain:

import scomp_link  # __init__.py loads ALL of these eagerly:
├── RegressorOptimizer    → sklearn, numpy, pandas
├── ModelFactory          → contrastive_text → torch + transformers  # 2.2s!
├── ShapExplainer         → shap (pulls sklearn+numpy+scipy)         # 2.3s!
├── ClassifierOptimizer   → sklearn.ensemble
├── TimeSeriesForecaster  → statsmodels
├── Validator             → plotly, matplotlib
└── ... (15+ more classes)

The Fix: v1.2.0 (PEP 562 __getattr__)

We replaced the eager imports with a single dispatcher. The entire __init__.py:

from .utils.logger import set_verbosity
__version__ = "1.2.0"

_LAZY_IMPORTS = {
    "RegressorOptimizer": (".models.regressor_optimizer", "RegressorOptimizer"),
    "ModelFactory":       (".models.model_factory", "ModelFactory"),
    "ShapExplainer":     (".explainability", "ShapExplainer"),
    # ... 19 total classes
}

def __getattr__(name):
    if name in _LAZY_IMPORTS:
        module_path, attr_name = _LAZY_IMPORTS[name]
        import importlib
        module = importlib.import_module(module_path, __package__)
        obj = getattr(module, attr_name)
        globals()[name] = obj  # Cache — next access is O(1)
        return obj
    raise AttributeError(f"module 'scomp_link' has no attribute {name!r}")

def __dir__():
    return list(_LAZY_IMPORTS.keys()) + ["set_verbosity", "__version__"]

The Results

Scenariov1.1.4 (Eager)v1.2.0 (Lazy)Speedup
import scomp_link5,192 ms5 ms1,000×
scomp-link --help4,530 ms~50 ms90×
Memory at import152 MB0.8 MB99.5% less
from scomp_link import DataQualityReport5,192 ms555 ms
from scomp_link import DriftDetector5,192 ms1,176 ms
from scomp_link import RegressorOptimizer5,192 ms3,818 ms1.4×

The key insight: import scomp_link went from 5.2 seconds to 5 milliseconds. Each class only loads its own dependency subtree on first access. A user who only needs DataQualityReport never pays for torch or transformers.

What About Python 3.15?

When we can raise the minimum to 3.15, the same logic becomes a one-line-per-import refactor:

# v1.3.0 (Python 3.15+ only) — same performance, cleaner code
from .utils.logger import set_verbosity
__version__ = "1.3.0"

lazy from .models.regressor_optimizer import RegressorOptimizer
lazy from .models.classifier_optimizer import ClassifierOptimizer
lazy from .models.model_factory import ModelFactory
lazy from .core import ScompLinkPipeline
lazy from .explainability import ShapExplainer, LimeExplainer
# ... clean, explicit, type-checker friendly

The performance numbers are identical — PEP 810 doesn't make things faster than __getattr__ with caching. What it gives you is: no boilerplate dict, native IDE support, and zero need for .pyi stubs. The adaptive interpreter specializes LOAD_GLOBALLOAD_GLOBAL_MODULE after 2–3 accesses, making it indistinguishable from an eager import on hot paths.

The Lesson

You don't need to wait for Python 3.15 to get the performance benefits of lazy imports. PEP 562 (__getattr__, available since 3.7) gives you 100% of the speed benefit today. PEP 810 gives you 100% of the ergonomics tomorrow. Ship the __getattr__ version now, refactor to lazy import when you can.


References