The Evolution of Lazy Imports in Python
“The best import is the one that never runs.” scomp-link case study
The Evolution of Lazy Imports in Python
“The best import is the one that never runs.” **scomp-link case study**
June 2026
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
impmodule removed → forces migration to fasterimportlibtypingmodule 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_GLOBAL → LOAD_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