← Back to list

The Evolution of Lazy Imports in Python

“The best import is the one that never runs.” scomp-link case study

Giacomo Saccaggi · 2026-06-25 16:07 · 0 claps · 6.4 min read
#lazy-import #python #programming #performance #python-tips
Open on Medium ↗
Wiki topics: 💻 · Programming

The Evolution of Lazy Imports in Python

“The best import is the one that never runs.” **scomp-link case study**

June 2026

[embed]The Evolution of Lazy Imports in Python The Evolution of Lazy Imports in Python: From Manual Workarounds to a First-Class Language Featuregiacomosaccaggi.github.io

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. Your serverless function cold-starts at 3 seconds instead of 50ms. Your test suite imports the universe before running a single assertion.

# 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:

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
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.

# 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.

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

No new lazy import mechanisms, but related improvements:

# Find your import bottlenecks (Python 3.7+)
$ python -X importtime -c "import your_app" 2>&1 | sort -t'|' -k2 -n | tail -5

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:

  • Frozen core modules: Interpreter essentials are statically allocated (bypassing .pyc loading). Startup 10–15% faster.
  • PEP 659 — Specializing Adaptive Interpreter: Bytecodes specialize to type-specific fast paths.
  • Overall: 10–60% faster than Python 3.10.

Python 3.12–3.13 (2023–2024): The Stdlib Diet

  • imp module removed → forces migration to faster importlib
  • typing module import time reduced by ~1/3
  • PEP 703 — Free-threaded CPython (experimental)
  • PEP 744 — Experimental JIT compiler

And then came the rejected proposal:

PEP 690 — Lazy Imports ❌ REJECTED (Dec 2022)

Proposal: Make ALL imports lazy by default, globally. Champion: Meta (Facebook), based on their Cinder fork (production on Instagram).

Why rejected:

  • Silent breakage of libraries relying on import-time side effects
  • Modified Python's dict internals, affecting ALL dictionary operations
  • No granular opt-out — all-or-nothing
  • Community fragmentation risk

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

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
# Bytecode AFTER 2-3 calls (adaptive interpreter specializes):
#   LOAD_GLOBAL_MODULE  0 (json)  ← direct dict access, ZERO check

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

The Complete Timeline

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+)

# Simple. Explicit. Zero-overhead.
lazy import tensorflow as tf
lazy import pandas as pd
lazy from scipy import optimize
def main(args):
    if args.command == "train":
        tf.keras.models.load_model(args.path)  # tf loads HERE
    elif args.command == "stats":
        pd.read_csv(args.file).describe()      # pd loads HERE

The import-time tax is finally dead. Long live lazy import. 🐍

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

[embed]scomp-link The Astromech arm for your Python data projects - end-to-end ML toolkitpypi.org

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)

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

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 your minimum Python is 3.15. Your users will thank you both times. The import-time tax is dead. 🐍

Full interactive version with benchmarks and code: The Evolution of Lazy Imports in Python


메타데이터
post_id
2d5abdc000b5
slug
the-evolution-of-lazy-imports-in-python-2d5abdc000b5
url
https://medium.com/@giacomo.saccaggi/the-evolution-of-lazy-imports-in-python-2d5abdc000b5
canonical_url
https://medium.com/@giacomo.saccaggi/the-evolution-of-lazy-imports-in-python-2d5abdc000b5
author_url
https://medium.com/@giacomo.saccaggi
status
ok
fetched_at
2026-08-06 20:16:35