"The best import is the one that never runs."
June 2026 · Giacomo Saccaggi
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.
We created a simulated "heavy" module (150ms initialization + 10MB allocation) and measured three strategies:
| Strategy | Module Used | Module NOT Used | ||
|---|---|---|---|---|
| Time (ms) | Memory (MB) | Time (ms) | Memory (MB) | |
| Eager (top-level) | 210 | 10.68 | 341 | 10.68 |
| Manual Lazy (inline) | 213 | 10.68 | 177 | 0.40 |
| Smart Lazy (LazyLoader) | 209 | 10.69 | 176 | 0.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 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.
__getattr__ and __dir__ ACCEPTEDIntroduced 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.
No new lazy import mechanisms, but related improvements:
| Version | Change | Impact on Imports |
|---|---|---|
| 3.8 | importlib.metadata added | Lighter alternative to pkg_resources (which was notoriously slow to import) |
| 3.9 | importlib.resources stabilized | Standard resource loading without heavy I/O at import time |
| 3.10 | -X importtime refined | Better 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
The single biggest performance leap in modern Python. While not adding new lazy mechanisms, it made ALL imports faster:
.pyc loading). Startup is 10–15% 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
imp module removed: Forces migration to the faster importlib.The standard library itself underwent an import-time diet:
typing module import time reduced by ~1/3 (removed dependencies on re and contextlib)email.utils, enum, functools, importlib.metadata, threading also fasterMeanwhile, PEP 690 had been rejected in late 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:
dict internals, affecting ALL dictionary operationsA 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
| Design Choice | PEP 690 (Rejected) | PEP 810 (Accepted) |
|---|---|---|
| Scope | Global — all imports lazy by default | Local — only where you write lazy |
| Visibility | Implicit — no code change needed | Explicit — visible at the import site |
| Cascading | Recursive into dependencies | Non-recursive — only affects that import |
| Implementation | Modified dict internals | Proxy objects + bytecode specialization |
| Adoption | All-or-nothing | Incremental, per-import |
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.
# 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
| Year | Python | Mechanism | Boilerplate |
|---|---|---|---|
| 2014 | 3.4 | importlib.util.LazyLoader | ~15 lines per module |
| 2018 | 3.7 | PEP 562: module __getattr__ | ~8 lines per module |
| 2019–2021 | 3.8–3.10 | Same patterns, better profiling | ~8 lines |
| 2022 | 3.11 | Faster CPython (all imports faster) | ~8 lines |
| 2022 | — | PEP 690 rejected (implicit global lazy) | — |
| 2023–2024 | 3.12–3.13 | Stdlib lighter, JIT foundations | ~8 lines |
| 2026 | 3.15 | PEP 810: lazy import keyword | 1 keyword |
# 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}")
# 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
ImportError at startup, not hidden in runtimescomp-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.
| Metric | Value |
|---|---|
import scomp_link | 5,192 ms |
scomp-link --help | 4,530 ms |
| Memory at import | 152.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)
__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__"]
| Scenario | v1.1.4 (Eager) | v1.2.0 (Lazy) | Speedup |
|---|---|---|---|
import scomp_link | 5,192 ms | 5 ms | 1,000× |
scomp-link --help | 4,530 ms | ~50 ms | 90× |
| Memory at import | 152 MB | 0.8 MB | 99.5% less |
from scomp_link import DataQualityReport | 5,192 ms | 555 ms | 9× |
from scomp_link import DriftDetector | 5,192 ms | 1,176 ms | 4× |
from scomp_link import RegressorOptimizer | 5,192 ms | 3,818 ms | 1.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.
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.
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.