from...import... statements?lazyfrommoduleimportClass load the entire module or just the class?TYPE_CHECKING imports?frommoduleimport*)?importlib.util.LazyLoader instead?isort orblack?dir(),getattr(), and module introspection?sys.modules? When does a lazy import appear there?lazyfrom__future__importfeature work?lazy as the keyword name?__lazy_modules__=["*"] as built-in syntaxwith blockswith blocks under the global flag__class__ mutationlazy imports find the module without loading itlazy keyword in the middle of from importslazy keyword at the end of import statementseager keywordglobals()__dict__ orglobals() accessThis PEP introduces syntax for lazy imports as an explicit language feature:
lazyimportjsonlazyfromjsonimportdumps
Lazy imports defer the loading and execution of a module until the first timethe imported name is used, in contrast to ‘normal’ imports, which eagerly loadand execute a module at the point of the import statement.
By allowing developers to mark individual imports as lazy with explicitsyntax, Python programs can reduce startup time, memory usage, and unnecessarywork. This is particularly beneficial for command-line tools, test suites, andapplications with large dependency graphs.
This proposal preserves full backwards compatibility: normal import statementsremain unchanged, and lazy imports are enabled only where explicitlyrequested.
The dominant convention in Python code is to place all imports at the modulelevel, typically at the beginning of the file. This avoids repetition, makesimport dependencies clear and minimizes runtime overhead by only evaluating animport statement once per module.
A major drawback with this approach is that importing the first module for anexecution of Python (the “main” module) often triggers an immediate cascade ofimports, and optimistically loads many dependencies that may never be used.The effect is especially costly for command-line tools with multiplesubcommands, where even running the command with--help can load dozens ofunnecessary modules and take several seconds. This basic example demonstrateswhat must be loaded just to get helpful feedback to the user on how to run theprogram at all. Inefficiently, the user incurs this overhead again when theyfigure out the command they want and invoke the program “for real.”
A somewhat common way to delay imports is to move the imports into functions(inline imports), but this practice requires more work to implement andmaintain, and can be subverted by a single inadvertent top-level import.Additionally, it obfuscates the full set of dependencies for a module.Analysis of the Python standard library shows that approximately 17% of allimports outside tests (nearly 3500 total imports across 730 files) are alreadyplaced inside functions or methods specifically to defer their execution. Thisdemonstrates that developers are already manually implementing lazy imports inperformance-sensitive code, but doing so requires scattering importsthroughout the codebase and makes the full dependency graph harder tounderstand at a glance.
The standard library provides theLazyLoader class tosolve some of these inefficiency problems. It permits imports at the modulelevel to workmostly like inline imports do. Many scientific Pythonlibraries have adopted a similar pattern, formalized inSPEC 1.There’s also the third-partylazy_loader package, yet anotherimplementation of lazy imports. Imports used solely for static type checkingare another source of potentially unneeded imports, and there are similarlydisparate approaches to minimizing the overhead. The various approaches usedhere to defer or remove eager imports do not cover all potential use-cases fora general lazy import mechanism. There is no clear standard, and there areseveral drawbacks including runtime overhead in unexpected places, or worseruntime introspection.
This proposal introduces syntax for lazy imports with a design that is local,explicit, controlled, and granular. Each of these qualities is essential tomaking the feature predictable and safe to use in practice.
The behavior islocal: laziness applies only to the specific import markedwith thelazy keyword, and it does not cascade recursively into otherimports. This ensures that developers can reason about the effect of lazinessby looking only at the line of code in front of them, without worrying aboutwhether imported modules will themselves behave differently. Alazyimportis an isolated decision each time it is used, not a global shift in semantics.
The semantics areexplicit. When a name is imported lazily, the binding iscreated in the importing module immediately, but the target module is notloaded until the first time the name is accessed. After this point, thebinding is indistinguishable from one created by a normal import. This clarityreduces surprises and makes the feature accessible to developers who may notbe deeply familiar with Python’s import machinery.
Lazy imports arecontrolled, in the sense that lazy loading is onlytriggered by the importing code itself. In the general case, a library willonly experience lazy imports if its own authors choose to mark them as such.This avoids shifting responsibility onto downstream users and preventsaccidental surprises in library behavior. Since library authors typicallymanage their own import subgraphs, they retain predictable control over whenand how laziness is applied.
The mechanism is alsogranular. It is introduced through explicit syntaxon individual imports, rather than a global flag or implicit setting. Thisallows developers to adopt it incrementally, starting with the mostperformance-sensitive areas of a codebase. As this feature is introduced tothe community, we want to make the experience of onboarding optional,progressive, and adaptable to the needs of each project.
Lazy imports provide several concrete advantages:
ifTYPE_CHECKING: blocks[1]. With lazy imports, annotation-only imports impose no runtimepenalty, eliminating the need for such guards and making annotated codebasescleaner.The design of this proposal is centered on clarity, predictability, and easeof adoption. Each decision was made to ensure that lazy imports providetangible benefits without introducing unnecessary complexity into the languageor its runtime.
It is also worth noting that while this PEP outlines one specific approach, welist alternate implementation strategies for some of the core aspects andsemantics of the proposal. If the community expresses a strong preference fora different technical path that still preserves the same core semantics orthere is fundamental disagreement over the specific option, we have includedthe brainstorming we have already completed in preparation for this proposalas reference.
The choice to introduce a newlazy keyword reflects the need for explicitsyntax. Lazy imports have different semantics from normal imports: errors andside effects occur at first use rather than at the import statement. Thissemantic difference makes it critical that laziness is visible at the importsite itself, not hidden in global configuration or distant module-leveldeclarations. Thelazy keyword provides local reasoning about importbehavior, avoiding the need to search elsewhere in the code to understandwhether an import is deferred. The rest of the import semantics remainunchanged: the same import machinery, module finding, and loading mechanismsare used.
Another important decision is to represent lazy imports with proxy objects inthe module’s namespace, rather than by modifying dictionary lookup. Earlierapproaches experimented with embedding laziness into dictionaries, but thisblurred abstractions and risked affecting unrelated parts of the runtime. Thedictionary is a fundamental data structure in Python – literally every objectis built on top of dicts – and adding hooks to dictionaries would preventcritical optimizations and complicate the entire runtime. The proxy approachis simpler: it behaves like a placeholder until first use, at which point itresolves the import and rebinds the name. From then on, the binding isindistinguishable from a normal import. This makes the mechanism easy toexplain and keeps the rest of the interpreter unchanged.
Compatibility for library authors was also a key concern. Many maintainersneed a migration path that allows them to support both new and old versions ofPython at once. For this reason, the proposal includes the__lazy_modules__ global as a transitional mechanism. A module candeclare which imports should be treated as lazy (by listing the module namesas strings), and on Python 3.15 or later those imports will become lazyautomatically, as if they were imported with thelazy keyword. On earlierversions the declaration is ignored, leaving imports eager. This gives authorsa practical bridge until they can rely on the keyword as the canonical syntax.
Finally, the feature is designed to be adopted incrementally. Nothing changesunless a developer explicitly opts in, and adoption can begin with just a fewimports in performance-sensitive areas. This mirrors the experience of gradualtyping in Python: a mechanism that can be introduced progressively, withoutforcing projects to commit globally from day one. Notably, the adoption canalso be done from the “outside in”, permitting CLI authors to introduce lazyimports and speed up user-facing tools, without requiring changes to everylibrary the tool might use.
lazyimport syntax,there are scenarios – such as large applications, testing environments,or frameworks – where enabling laziness consistently acrossmany modules provides the most benefit. A global switch makes it easy toexperiment with or enforce consistent behavior, while still working incombination with the filtering API to respect exclusions or tool-specificconfiguration. This ensures that global adoption can be practical withoutreducing flexibility or control.A new soft keywordlazy is added. A soft keyword is a context-sensitivekeyword that only has special meaning in specific grammatical contexts;elsewhere it can be used as a regular identifier (e.g., as a variable name).Thelazy keyword only has special meaning when it appears before importstatements:
import_name: | 'lazy'? 'import' dotted_as_namesimport_from: | 'lazy'? 'from' ('.' | '...')* dotted_name 'import' import_from_targets | 'lazy'? 'from' ('.' | '...')+ 'import' import_from_targetsThe soft keyword is only allowed at the global (module) level,not insidefunctions, class bodies,try blocks, orimport*. Importstatements that use the soft keyword arepotentially lazy. Imports thatcan’t be lazy are unaffected by the global lazy imports flag, and instead arealways eager. Additionally,from__future__import statements cannot belazy.
Examples of syntax errors:
# SyntaxError: lazy import not allowed inside functionsdeffoo():lazyimportjson# SyntaxError: lazy import not allowed inside classesclassBar:lazyimportjson# SyntaxError: lazy import not allowed inside try/except blockstry:lazyimportjsonexceptImportError:pass# SyntaxError: lazy from ... import * is not allowedlazyfromjsonimport*# SyntaxError: lazy from __future__ import is not allowedlazyfrom__future__importannotations
When thelazy keyword is used, the import becomespotentially lazy(seeLazy imports filter for advanced override mechanisms). The module is notloaded immediately at the import statement; instead, a lazy proxy object iscreated and bound to the name. The actual module is loaded on first use ofthat name.
When usinglazyfrom...import,each imported name is bound to a lazyproxy object. The first access toany of these names triggers loading ofthe entire module and reifiesonly that specific name to its actual value.Other names remain as lazy proxies until they are accessed. The interpreter’sadaptive specialization will optimize away the lazy checks after a few accesses.
Example withlazyimport:
importsyslazyimportjsonprint('json'insys.modules)# False - module not loaded yet# First use triggers loadingresult=json.dumps({"hello":"world"})print('json'insys.modules)# True - now loaded
Example withlazyfrom...import:
importsyslazyfromjsonimportdumps,loadsprint('json'insys.modules)# False - module not loaded yet# First use of 'dumps' triggers loading json and reifies ONLY 'dumps'result=dumps({"hello":"world"})print('json'insys.modules)# True - module now loaded# Accessing 'loads' now reifies it (json already loaded, no re-import)data=loads(result)
A module may define a__lazy_modules__ variable in its global scope,which specifies which module names should be madepotentially lazy (as if thelazy keyword was used). This variable is checked on eachimportstatement to determine whether the import should be madepotentially lazy.The check is performed by calling__contains__ on the__lazy_modules__ object with a string containing the fully qualifiedmodule name being imported. Typically,__lazy_modules__ is a set offully qualified module name strings. When a module is made lazy this way,from-imports using that module are also lazy, but not necessarily imports ofsub-modules.
The normal (non-lazy) import statement will check the global lazy importsflag. If it is “all”, all imports arepotentially lazy (except forimports that can’t be lazy, as mentioned above.)
Example:
__lazy_modules__=["json"]importjsonprint('json'insys.modules)# Falseresult=json.dumps({"hello":"world"})print('json'insys.modules)# True
If the global lazy imports flag is set to “none”, nopotentially lazyimport is ever imported lazily, and the behavior is equivalent to a regularimport statement: the import iseager (as if the lazy keyword was not used).
Finally, the application may use a custom filter function on allpotentiallylazy imports to determine if they should be lazy or not (this is an advancedfeature, seeLazy imports filter). If a filter function is set, it will becalled with the name of the module doing the import, the name of the modulebeing imported, and (if applicable) the fromlist. An import remains lazy onlyif the filter function returnsTrue. If no lazy import filter is set, allpotentially lazy imports are lazy.
The lazy import mechanism does not apply to .pth files processed by thesite module. While .pth files have special handling for lines that beginwithimport followed by a space or tab, this special handling will not beadapted to support lazy imports. Imports specified in .pth files remain eageras they always have been.
Lazy modules, as well as names lazy imported from modules, are representedbytypes.LazyImportType instances, which are resolved to the realobject (reified) before they can be used. This reification is usually doneautomatically (see below), but can also be done by calling the lazy object’sresolve method.
When an import is lazy,__lazy_import__ is called instead of__import__.__lazy_import__ has the same function signature as__import__. It adds the module name tosys.lazy_modules, a set offully-qualified module names which have been lazily imported at some point(primarily for diagnostics and introspection), and returns atypes.LazyImportType object for the module.
The implementation offrom...import (theIMPORT_FROM bytecodeimplementation) checks if the module it’s fetching from is a lazy moduleobject, and if so, returns atypes.LazyImportType for each nameinstead.
The end result of this process is that lazy imports (regardless of how theyare enabled) result in lazy objects being assigned to global variables.
Lazy module objects do not appear insys.modules, they’re just listed inthesys.lazy_modules set. Under normal operation lazy objects should onlyend up stored in global variables, and the common ways to access thosevariables (regular variable access, module attributes) will resolve lazyimports (reify) and replace them when they’re accessed.
It is still possible to expose lazy objects through other means, likedebuggers. This is not considered a problem.
When a lazy object is used, it needs to be reified. This means resolving theimport at that point in the program and replacing the lazy object with theconcrete one. Reification imports the module at that point in the program.Notably, reification still calls__import__ to resolve the import, whichuses the state of the import system (e.g.sys.path,sys.meta_path,sys.path_hooks and__import__) atreification time,not thestate when thelazyimport statement was evaluated.
When the module is reified, it’s removed fromsys.lazy_modules (even ifthere are still other unreified lazy references to it). When a package isreified and submodules in the package were also previously lazily imported,those submodules arenot automatically reified but theyare added to thereified package’s globals (unless the package already assigned somethingelse to the name of the submodule).
If reification fails (e.g., due to anImportError), the lazy object isnot reified or replaced. Subsequent uses of the lazy object will re-trythe reification. Exceptions that happen during reification are raised asnormal, but the exception is enhanced with chaining to show both where thelazy import was defined and where it was accessed (even though it propagatesfrom the code that triggered reification). This provides clear debugginginformation:
# app.py - has a typo in the importlazyfromjsonimportdumsp# Typo: should be 'dumps'print("App started successfully")print("Processing data...")# Error occurs here on first useresult=dumsp({"key":"value"})
The traceback shows both locations:
App started successfullyProcessing data...Traceback (most recent call last): File"app.py", line2, in<module>lazyfromjsonimportdumspImportError:lazy import of 'json.dumsp' raised an exception during resolutionThe above exception was the direct cause of the following exception:Traceback (most recent call last): File"app.py", line8, in<module>result=dumsp({"key":"value"})^^^^^ImportError:cannot import name 'dumsp' from 'json'. Did you mean: 'dump'?
This exception chaining clearly shows:
Reification doesnot automatically occur when a module that was previouslylazily imported is subsequently eagerly imported. Reification doesnotimmediately resolve all lazy objects (e.g.lazyfrom statements) thatreferenced the module. Itonly resolves the lazy object being accessed.
Accessing a lazy object (from a global variable or a module attribute) reifiesthe object.
However, callingglobals() or accessing a module’s__dict__ doesnot trigger reification – they return the module’s dictionary, andaccessing lazy objects through that dictionary still returns lazy proxyobjects that need to be manually reified upon use. A lazy object can beresolved explicitly by calling theresolve method. Callingdir() atthe global scope will not reify the globals, nor will callingdir(mod)(through special-casing inmod.__dir__.) Other, more indirect ways ofaccessing arbitrary globals (e.g. inspectingframe.f_globals) also donot reify all the objects.
Example usingglobals() and__dict__:
# my_module.pyimportsyslazyimportjson# Calling globals() does NOT trigger reificationg=globals()print('json'insys.modules)# False - still lazyprint(type(g['json']))# <class 'LazyImport'># Accessing __dict__ also does NOT trigger reificationd=__dict__print(type(d['json']))# <class 'LazyImport'># Explicitly reify using the resolve() methodresolved=g['json'].resolve()print(type(resolved))# <class 'module'>print('json'insys.modules)# True - now loaded
A reference implementation is available at:https://github.com/LazyImportsCabal/cpython/tree/lazy
A demo is available (not necessarily synced with the latest PEP) forevaluation purposes at:https://lazy-import-demo.pages.dev/
Lazy imports are implemented through modifications to four bytecodeinstructions:IMPORT_NAME,IMPORT_FROM,LOAD_GLOBAL, andLOAD_NAME.
Thelazy syntax sets a flag in theIMPORT_NAME instruction’s oparg(oparg&0x01). The interpreter checks this flag and calls_PyEval_LazyImportName() instead of_PyEval_ImportName(), creating alazy import object rather than executing the import immediately. TheIMPORT_FROM instruction checks whether its source is a lazy import(PyLazyImport_CheckExact()) and creates a lazy object for the attributerather than accessing it immediately.
When a lazy object is accessed, it must be reified. TheLOAD_GLOBALinstruction (used in function scopes) andLOAD_NAME instruction (used atmodule and class level) both check whether the object being loaded is a lazyimport. If so, they call_PyImport_LoadLazyImportTstate() to perform theactual import and store the module insys.modules.
This check incurs a very small cost on each access. However, Python’s adaptiveinterpreter can specializeLOAD_GLOBAL after observing that a lazy importhas been reified. After several executions,LOAD_GLOBAL becomesLOAD_GLOBAL_MODULE, which accesses the module dictionary directly withoutchecking for lazy imports.
Examples of the bytecode generated:
lazyimportjson# IMPORT_NAME with flag set
Generates:
IMPORT_NAME 1 (json + lazy)
lazyfromjsonimportdumps# IMPORT_NAME + IMPORT_FROM
Generates:
IMPORT_NAME 1 (json + lazy)IMPORT_FROM 1 (dumps)
lazyimportjsonx=json# Module-level access
Generates:
LOAD_NAME 0 (json)
lazyimportjsondefuse_json():returnjson.dumps({})# Function scope
Before any calls:
LOAD_GLOBAL 0 (json)LOAD_ATTR 2 (dumps)
After several calls,LOAD_GLOBAL specializes toLOAD_GLOBAL_MODULE:
LOAD_GLOBAL_MODULE 0 (json)LOAD_ATTR_MODULE 2 (dumps)
Note: This is an advanced feature. These are intended for specialized/advancedusers who need fine-grained control over lazy import behavior when using theglobal flags. Library developers are discouraged from using these functions asthey can affect the runtime execution of applications (similar to``sys.setrecursionlimit()``, ``sys.setswitchinterval()``, or``gc.set_threshold()``).
This PEP adds the following new functions to thesys module to manage thelazy imports filter:
sys.set_lazy_imports_filter(func) - Sets the filter function. Iffunc=None then the import filter is removed. Thefunc parameter musthave the signature:func(importer:str,name:str,fromlist:tuple[str,...]|None)->boolsys.get_lazy_imports_filter() - Returns the currently installedfilter function, orNone if no filter is set.sys.set_lazy_imports(mode,/) - Programmatic API forcontrolling lazy imports at runtime. Themode parameter can be"normal" (respectlazy keyword only),"all" (force all imports to bepotentially lazy), or"none" (force all imports to be eager).sys.get_lazy_imports() - Returns the current lazy imports mode as astring:"normal","all", or"none".The filter function is called for every potentially lazy import, and mustreturnTrue if the import should be lazy. This allows for fine-grainedcontrol over which imports should be lazy, useful for excluding modules withknown side-effect dependencies or registration patterns. The filter functionis called at the point of execution of the lazy import or lazy from importstatement, not at the point of reification. The filter function may becalled concurrently.
The filter mechanism serves as a foundation that tools, debuggers, linters,and other ecosystem utilities can leverage to provide better lazy importexperiences. For example, static analysis tools could detect modules with sideeffects and automatically configure appropriate filters.In the future(out of scope for this PEP), this foundation may enable better ways todeclaratively specify which modules are safe for lazy importing, such aspackage metadata, type stubs with lazy-safety annotations, or configurationfiles. The current filter API is designed to be flexible enough to accommodatesuch future enhancements without requiring changes to the core languagespecification.
Example:
importsysdefexclude_side_effect_modules(importer,name,fromlist):""" Filter function to exclude modules with import-time side effects. Args: importer: Name of the module doing the import name: Name of the module being imported fromlist: Tuple of names being imported (for 'from' imports), or None Returns: True to allow lazy import, False to force eager import """# Modules known to have important import-time side effectsside_effect_modules={'legacy_plugin_system','metrics_collector'}ifnameinside_effect_modules:returnFalse# Force eager importreturnTrue# Allow lazy import# Install the filtersys.set_lazy_imports_filter(exclude_side_effect_modules)# These imports are checked by the filterlazyimportdata_processor# Filter returns True -> stays lazylazyimportlegacy_plugin_system# Filter returns False -> imported eagerlyprint('data_processor'insys.modules)# False - still lazyprint('legacy_plugin_system'insys.modules)# True - loaded eagerly# First use of data_processor triggers loadingresult=data_processor.transform(data)print('data_processor'insys.modules)# True - now loaded
Note: This is an advanced feature. This is intended for application developersand framework authors who need to control lazy imports across their entireapplication. Library developers are discouraged from using the global activationmechanism as it can affect the runtime execution of applications (similar to``sys.setrecursionlimit()``, ``sys.setswitchinterval()``, or``gc.set_threshold()``).
The global lazy imports flag can be controlled through:
-Xlazy_imports=<mode> command-line optionPYTHON_LAZY_IMPORTS=<mode> environment variablesys.set_lazy_imports(mode) function (primarily for testing)The precedence order for setting the lazy imports mode follows the standardPython pattern:sys.set_lazy_imports() takes highest precedence, followedby-Xlazy_imports=<mode>, thenPYTHON_LAZY_IMPORTS=<mode>. If noneare specified, the mode defaults to"normal".
Where<mode> can be:
"normal" (or unset): Only explicitly marked lazy imports are lazy"all": All module-level imports (except intryblocks andimport*) becomepotentially lazy"none": No imports are lazy, even those explicitly marked withlazy keywordWhen the global flag is set to"all", all imports at the global levelof all modules arepotentially lazyexcept for those inside atryblock or any wild card (from...import*) import.
If the global lazy imports flag is set to"none", nopotentiallylazy import is ever imported lazily, the import filter is never called, andthe behavior is equivalent to a regularimport statement: the import iseager (as if the lazy keyword was not used).
Python code can run thesys.set_lazy_imports() function to overridethe state of the global lazy imports flag inherited from the environment or CLI.This is especially useful if an application needs to ensure that all importsare evaluated eagerly, viasys.set_lazy_imports("none").
Lazy imports areopt-in. Existing programs continue to run unchangedunless a project explicitly enables laziness (vialazy syntax,__lazy_modules__, or an interpreter-wide switch).
import andfrom...import... statements remain eagerunless explicitly madepotentially lazy by the local or global mechanismsprovided.__import__() andimportlib.import_module().These changes are limited to bindings explicitly made lazy:
ImportError orAttributeError for a missing member) nowoccur at theuse of the lazy name.# With eager import - error at import statementimportbroken_module# ImportError raised here# With lazy import - error deferredlazyimportbroken_moduleprint("Import succeeded")broken_module.foo()# ImportError raised here on use
sys.modules until first use. After reification, it must appear insys.modules. If some other code eagerly imports the same module beforefirst use, the lazy binding resolves to that existing (lazy) module objectwhen it is first used.Reification follows the existing import-lock discipline. Exactly one threadperforms the import andatomically rebinds the importing module’s globalto the resolved object. Concurrent readers thereafter observe the realobject.
Lazy imports are thread-safe and have no special considerations forfree-threading. A module that would normally be imported in the main threadmay be imported in a different thread if that thread triggers the first accessto the lazy import. This is not a problem: the import lock ensures threadsafety regardless of which thread performs the import.
Subinterpreters are supported. Each subinterpreter maintains its ownsys.lazy_modules and import state, so lazy imports in one subinterpreterdo not affect others.
Lazy imports haveno measurable performance overhead. The implementationis designed to be performance-neutral for both code that uses lazy imports andcode that doesn’t.
After reification (provided the import was successful), lazy imports havezero overhead. The adaptive interpreter specializes the bytecode(typically after 2-3 accesses), eliminating any checks. For example,LOAD_GLOBAL becomesLOAD_GLOBAL_MODULE, which directly accesses themodule identically to normal imports.
Thepyperformance suite confirms the implementation is performance-neutral.
The filter function (set viasys.set_lazy_imports_filter()) is called foreverypotentially lazy import to determine whether it should actually belazy. When no filter is set, this is simply a NULL check (testing whether afilter function has been registered), which is a highly predictable branch thatadds essentially no overhead. When a filter is installed, it is called for eachpotentially lazy import, but this still hasalmost no measurable performancecost. To measure this, we benchmarked importing all 278 top-level importablemodules from the Python standard library (which transitively loads 392 totalmodules including all submodules and dependencies), then forced reification ofevery loaded module to ensure everything was fully materialized.
Note that these measurements establish the baseline overhead of the filtermechanism itself. Of course, any user-defined filter function that performsadditional work beyond a trivial check will add overhead proportional to thecomplexity of that work. However, we expect that in practice this overheadwill be dwarfed by the performance benefits gained from avoiding unnecessaryimports. The benchmarks below measure the minimal cost of the filter dispatchmechanism when the filter function does essentially nothing.
We compared four different configurations:
| Configuration | Mean ± Std Dev (ms) | Overhead vs Baseline |
|---|---|---|
| Eager imports (baseline) | 161.2 ± 4.3 | 0% |
| Lazy + filter forcing eager | 161.7 ± 4.2 | +0.3% ± 3.7% |
| Lazy + filter allowing lazy + reification | 162.0 ± 4.0 | +0.5% ± 3.7% |
| Lazy + no filter + reification | 161.4 ± 4.3 | +0.1% ± 3.8% |
The four configurations:
False for allimports, forcing eager execution, then all imports are reified at scriptend. Measures pure filter calling overhead since every import goes throughthe filter but executes eagerly.True for all imports, allowing lazy execution. All imports are reifiedat script end. Measures filter overhead when imports are actually lazy.The benchmarks usedhyperfine,testing 278 standard library modules. Each ran in a fresh Python process.All configurations force the import of exactly the same set of modules(all modules loaded by the eager baseline) to ensure a fair comparison.
The benchmark environment used CPU isolation with 32 logical CPUs (0-15 at3200 MHz, 16-31 at 2400 MHz), the performance scaling governor, Turbo Boostdisabled, and full ASLR randomization. The overhead error bars are computedusing standard error propagation for the formula(value-baseline)/baseline, accounting for uncertainties in both the measured value and thebaseline.
The primary performance benefit of lazy imports is reduced startup time byloading only the modules actually used at runtime, rather than optimisticallyloading entire dependency trees at startup.
Real-world deployments at scale have demonstrated that the benefits can bemassive, though of course this depends on the specific codebase and usagepatterns. Organizations with large, interconnected codebases have reportedsubstantial reductions in server reload times, ML training initialization,command-line tool startup, and Jupyter notebook loading. Memory usageimprovements have also been observed as unused modules remain unloaded.
For detailed case studies and performance data from production deployments,see:
The benefits scale with codebase complexity: the larger and moreinterconnected the codebase, the more dramatic the improvements. ThePySide implementation particularly highlights how frameworks with heavyinitialization overhead can benefit significantly from opt-in lazy loading.
Type checkers and static analyzers may treatlazy imports as ordinaryimports for name resolution. At runtime, annotation-only imports can be markedlazy to avoid startup overhead. IDEs and debuggers should be prepared todisplay lazy proxies before first use and the real objects thereafter.
Tools that install packages while performing imports from that the sameenvironment should ensure all modules are imported eagerly, or reified, beforethe installation step, to avoid newly installed distributions from shadowingthem.
Such tools can usesys.set_lazy_imports() with"none" toforce eager evaluation, or provide asys.set_lazy_imports_filter() function forfine-grained control.
The newlazy keyword will be documented as part of the language standard.
As this feature is opt-in, new Python users should be able to continue usingthe language as they are used to. For experienced developers, we expect themto leverage lazy imports for the variety of benefits listed above (decreasedlatency, decreased memory usage, etc) on a case-by-case basis. Developersinterested in the performance of their Python binary will likely leverageprofiling to understand the import time overhead in their codebase and markthe necessary imports aslazy. In addition, developers can mark importsthat will only be used for type annotations aslazy.
Additional documentation will be added to the Python documentation, includingguidance, a dedicated how-to guide, and updates to the import systemdocumentation covering: identifying slow-loading modules with profiling tools(such as-Ximporttime), migration strategies for existing codebases, bestpractices for avoiding common pitfalls with import-time side effects, andpatterns for using lazy imports effectively with type annotations and circularimports.
Below is guidance on how to best take advantage of lazy imports and how toavoid incompatibilities:
__init_subclass__. Instead, registries ofobjects should be constructed via explicit discovery processes (e.g. awell-known function to call).# Problematic: Plugin registers itself on import# my_plugin.pyfromplugin_registryimportregister_plugin@register_plugin("MyPlugin")classMyPlugin:pass# In main code:lazyimportmy_plugin# Plugin NOT registered yet - module not loaded!# Better: Explicit discovery# plugin_registry.pydefdiscover_plugins():frommy_pluginimportMyPluginregister_plugin(MyPlugin)# In main code:plugin_registry.discover_plugins()# Explicit loading
from.importbar infoo/__init__.py, always useimportfoo.bar;foo.bar.Baz, notimportfoo;foo.bar.Baz. The latter only works (unreliably) because theattributefoo.bar is added as a side effect offoo.bar beingimported somewhere else.lazykeyword. This allows them to keep dependencies clear and avoid the overheadof repeatedly re-resolving the import but will still speed up the program.# Before: Inline import (repeated overhead)defprocess_data(data):importjson# Re-resolved on every callreturnjson.dumps(data)# After: Lazy import at module levellazyimportjsondefprocess_data(data):returnjson.dumps(data)# Loaded once on first call
PEP 810 takes an explicit, opt-in approach instead ofPEP 690’s implicitglobal approach. The key differences are:
lazyimportfoo clearly marks which imports arelazy.What changes (the timing):
What stays the same (everything else):
__import__, same hooks, same loaderssys.path,sys.meta_path, etc. atreification time (not at import statement time)In other words: lazy imports only changewhen something happens, notwhat happens. After reification, a lazy-imported module isindistinguishable from an eagerly imported one.
Import errors (ImportError,ModuleNotFoundError, syntax errors) aredeferred until first use of the lazy name. This is similar to moving an importinto a function. The error will occur with a clear traceback pointing to thefirst access of the lazy object.
The implementation provides enhanced error reporting through exceptionchaining. When a lazy import fails during reification, the original exceptionis preserved and chained, showing both where the import was defined and whereit was first used:
Traceback (most recent call last): File"test.py", line1, in<module>lazyimportbroken_moduleImportError:lazy import of 'broken_module' raised an exception during resolutionThe above exception was the direct cause of the following exception:Traceback (most recent call last): File"test.py", line3, in<module>broken_module.foo()^^^^^^^^^^^^^ File"broken_module.py", line2, in<module>1/0ZeroDivisionError:division by zero
Exceptions during reification prevent the replacement of the lazy object,and subsequent uses of the lazy object will retry the whole reification.
Side effects are deferred until first use. This is generally desirable forperformance, but may require code changes for modules that rely on import-timeregistration patterns. We recommend:
from...import... statements?Yes, as long as you don’t usefrom...import*. Bothlazyimportfoo andlazyfromfooimportbar are supported. Thebar name will bebound to a lazy object that resolves tofoo.bar on first use.
lazyfrommoduleimportClass load the entire module or just the class?It loads theentire module, not just the class. This is becausePython’s import system always executes the complete module file – there’s nomechanism to execute only part of a.py file. When you first accessClass, Python:
module.py fileClass attribute from the resulting module objectClass to the name in your namespaceThis is identical to eagerfrommoduleimportClass behavior. The onlydifference with lazy imports is that steps 1-3 happen on first use instead ofat the import statement.
# heavy_module.pyprint("Loading heavy_module")# This ALWAYS runs when module loadsclassMyClass:passclassUnusedClass:pass# Also gets defined, even though we don't import it# app.pylazyfromheavy_moduleimportMyClassprint("Import statement done")# heavy_module not loaded yetobj=MyClass()# NOW "Loading heavy_module" prints# (and UnusedClass gets defined too)
Key point: Lazy imports deferwhen a module loads, notwhat getsloaded. You cannot selectively load only parts of a module – Python’s importsystem doesn’t support partial module execution.
TYPE_CHECKING imports?Lazy imports eliminate the common need forTYPE_CHECKING guards. Youcan write:
lazyfromcollections.abcimportSequence,Mapping# No runtime costdefprocess(items:Sequence[str])->Mapping[str,int]:...
Instead of:
fromtypingimportTYPE_CHECKINGifTYPE_CHECKING:fromcollections.abcimportSequence,Mappingdefprocess(items:Sequence[str])->Mapping[str,int]:...
The overhead is minimal:
Benchmarking with thepyperformance suite shows the implementation isperformance neutral when lazy imports are not used.
Yes. If modulefoo is imported both lazily and eagerly in the sameprogram, the eager import takes precedence and both bindings resolve to thesame module object.
Migration is incremental:
lazy keyword to imports that aren’t needed immediately.__lazy_modules__ for compatibility with older Python versions.frommoduleimport*)?Wild card (star) imports cannot be lazy - they remain eager. This isbecause the set of names being imported cannot be determined without loadingthe module. Using thelazy keyword with star imports will be a syntaxerror. If lazy imports are globally enabled, star imports will still be eager.
Import hooks and loaders work normally. When a lazy object is used,the standard import protocol runs, including any custom hooks or loaders thatwere in place at reification time.
Lazy import reification is thread-safe. Only one thread will perform theactual import, and the binding is atomically updated. Other threads will seeeither the lazy proxy or the final resolved object.
Yes, individual lazy objects can be resolved by calling theirresolve()method.
importlib.util.LazyLoader instead?The standard library’sLazyLoader was designed forspecific use cases but has fundamental limitations as a general-purpose lazyimport mechanism.
Most critically,LazyLoader does not supportfrom...import statements.There is no straightforward mechanism to lazily import specific attributes froma module - users would need to manually wrap and proxy individual attributes,which is both error-prone and defeats the performance benefits.
Additionally,LazyLoader must resolve the module spec before creating thelazy loader, which introduces overhead that reduces the performance benefits oflazy loading. The spec resolution involves filesystem operations and pathsearching that this PEP’s approach defers until actual module use.
LazyLoader also operates at the import machinery level rather than providinglanguage-level syntax, which means there’s no canonical way for tools likelinters and type checkers to recognize lazy imports. A dedicated syntax enablesecosystem-wide standardization and allows compiler and runtime optimizationsthat would be impossible with a purely library-based approach.
Finally,LazyLoader requires significant boilerplate, involving manualmanipulation of module specs, loaders, andsys.modules, making it impracticalfor common use cases where multiple modules need to be lazily imported.
isort orblack?Linters, formatters, and other tools will need updates to recognizethelazy keyword, but the changes should be minimal since the importstructure remains the same. The keyword appears at the beginning,making it easy to parse.
Most libraries should work fine with lazy imports. Libraries that mighthave issues:
When in doubt, test lazy imports with your specific use cases.
Note: This is an advanced feature. You can use the lazy imports filter toexclude specific modules that are known to have problematic side effects:
importsysdefmy_filter(importer,name,fromlist):# Don't lazily import modules known to have side effectsifnamein{'problematic_module','another_module'}:returnFalse# Import eagerlyreturnTrue# Allow lazy importsys.set_lazy_imports_filter(my_filter)
The filter function receives the importer module name, the module beingimported, and the fromlist (if usingfrom...import). ReturningFalseforces an eager import.
Alternatively, set the global mode to"none" via-Xlazy_imports=none to turn off all lazy imports for debugging.
No, thelazy keyword is only allowed at module level. Forfunction-level lazy loading, use traditional inline imports or move the importto module level withlazy.
Use the__lazy_modules__ global for compatibility:
# Works on Python 3.15+ as lazy, eager on older versions__lazy_modules__=['expensive_module','expensive_module_2']importexpensive_modulefromexpensive_module_2importMyClass
The__lazy_modules__ attribute is a list of module name strings. Whenan import statement is executed, Python checks if the module name beingimported appears in__lazy_modules__. If it does, the import istreated as if it had thelazy keyword (becomingpotentially lazy). OnPython versions before 3.15 that don’t support lazy imports, the__lazy_modules__ attribute is simply ignored and imports proceedeagerly as normal.
This provides a migration path until you can rely on thelazy keyword. Formaximum predictability, it’s recommended to define__lazy_modules__once, before any imports. But as it is checked on each import, it can bemodified betweenimport statements.
Python 3.14 implemented deferred evaluation of annotations,as specified byPEP 649 andPEP 749.If an annotation is not stringified, it is an expression that is evaluatedat a later time. It will only be resolved if the annotation is accessed. Inthe example below, thefake_typing module is only loaded when the userinspects the__annotations__ dictionary. Thefake_typing module wouldalso be loaded if the user usesannotationlib.get_annotations() orgetattr to access the annotations.
lazyfromfake_typingimportMyFakeTypedeffoo(x:MyFakeType):passprint(foo.__annotations__)# Triggers loading the fake_typing module
dir(),getattr(), and module introspection?Accessing lazy imports through normal attribute access orgetattr()will trigger reification of the accessed attribute. Callingdir() on amodule will be special cased inmod.__dir__ to avoid reification.
lazyimportjson# Before any access# json not in sys.modules# Any of these trigger reification:dumps_func=json.dumpsdumps_func=getattr(json,'dumps')# Now json is in sys.modules
Lazy imports don’t automatically solve circular import problems. If twomodules have a circular dependency, making the imports lazy might helponlyif the circular reference isn’t accessed during module initialization.However, if either module accesses the other during import time, you’ll stillget an error.
Example that works (deferred access in functions):
# user_model.pylazyimportpost_modelclassUser:defget_posts(self):# OK - post_model accessed inside function, not during importreturnpost_model.Post.get_by_user(self.name)# post_model.pylazyimportuser_modelclassPost:@staticmethoddefget_by_user(username):returnf"Posts by{username}"
This works because neither module accesses the other at module level – theaccess happens later whenget_posts() is called.
Example that fails (access during import):
# module_a.pylazyimportmodule_bresult=module_b.get_value()# Error! Accessing during importdeffunc():return"A"# module_b.pylazyimportmodule_aresult=module_a.func()# Circular dependency error heredefget_value():return"B"
This fails becausemodule_a tries to accessmodule_b at import time,which then tries to accessmodule_a before it’s fully initialized.
The best practice is still to avoid circular imports in your code design.
After first use (provided the import succeed), lazy imports havezerooverhead thanks to the adaptive interpreter. The interpreter specializesthe bytecode (e.g.,LOAD_GLOBAL becomesLOAD_GLOBAL_MODULE) whicheliminates the lazy check on subsequent accesses. This means once a lazyimport is reified, accessing it is just as fast as a normal import.
lazyimportjsondefuse_json():returnjson.dumps({"test":1})# First call triggers reificationuse_json()# After 2-3 calls, bytecode is specializeduse_json()use_json()
You can observe the specialization usingdis.dis(use_json,adaptive=True):
=== Before specialization ===LOAD_GLOBAL 0 (json)LOAD_ATTR 2 (dumps)=== After 3 calls (specialized) ===LOAD_GLOBAL_MODULE 0 (json)LOAD_ATTR_MODULE 2 (dumps)
The specializedLOAD_GLOBAL_MODULE andLOAD_ATTR_MODULE instructionsare optimized fast paths with no overhead for checking lazy imports.
sys.modules? When does a lazy import appear there?A lazily imported module doesnot appear insys.modules until it’sreified (first used). Once reified, it appears insys.modules just likeany eager import.
importsyslazyimportjsonprint('json'insys.modules)# Falseresult=json.dumps({"key":"value"})# First useprint('json'insys.modules)# True
lazyfrom__future__importfeature work?No, future imports can’t be lazy because they’re parser/compiler directives.It’s technically possible for the runtime behavior to be lazy but there’s noreal value in it.
lazy as the keyword name?Not “why”… memorize! :)
The following ideas have been considered but are deliberately deferred to focuson delivering a stable, usable core feature first. These may be considered forfuture enhancements once we have real-world experience with lazy imports.
Several alternative syntax forms have been suggested to improve ergonomics:
type keyword in other contexts) could beadded, such astypefromcollections.abcimportSequence. This would makethe intent clearer than usinglazy for type-only imports and would signalto readers that the import is never used at runtime. However, sincelazyimports already solve the runtime cost problem for type annotations, we preferto start with the simpler, more general mechanism and evaluate whetherspecialized syntax adds sufficient value after gathering usage data.aslazy:importfoofrombarimportbaz
This could reduce repetition when marking many imports as lazy. However, itwould require introducing an entirely new statement form (aslazy: blocks)that doesn’t fit into Python’s existing grammar patterns. It’s unclear howthis would interact with other language features or what the precedent wouldbe for similar block-level modifiers. This approach also makes it less clearwhen scanning code whether a particular import is lazy, since you must look atthe surrounding context rather than the import line itself.
While these alternatives could provide different ergonomics in certain contexts,they share similar drawbacks: they would require introducing new statementforms or overloading existing syntax in non-obvious ways, and they open thedoor to many other potential uses of similar syntax patterns that wouldsignificantly expand the language. We prefer to start with the explicitlazyimport syntax and gather real-world feedback before consideringadditional syntax variations. Any future ergonomic improvements should beevaluated based on actual usage patterns rather than speculative benefits.
ifTYPE_CHECKING blocksA future enhancement could automatically treat all imports insideifTYPE_CHECKING: blocks as lazy:
fromtypingimportTYPE_CHECKINGifTYPE_CHECKING:fromfooimportBar# Could be automatically lazy
However, this would require significant changes to make this work at compiletime, sinceTYPE_CHECKING is currently just a runtime variable. Thecompiler would need special knowledge of this pattern, similar to howfrom__future__import statements are handled. Additionally, makingTYPE_CHECKING a built-in would be required for this to work reliably.Sincelazy imports already solve the runtime cost problem for type-onlyimports, we prefer to start with the explicit syntax and evaluate whetherthis optimization adds sufficient value.
A module-level declaration to make all imports in that module lazy by default:
from__future__importlazy_importsimportfoo# Automatically lazy
This was discussed but deferred because it raises several questions. Usingfrom__future__import implies this would become the default behavior in afuture Python version, which is unclear and not currently planned. It alsoraises questions about how such a mode would interact with the global flag andwhat the transition path would look like. The current explicit syntax and__lazy_modules__ provide sufficient control for initial adoption.
Future enhancements could allow packages to declare in their metadata whetherthey are safe for lazy importing (e.g., no import-time side effects). Thiscould be used by the filter mechanism or by static analysis tools. The currentfilter API is designed to accommodate such future additions without requiringchanges to the core language specification.
No dedicated C API is planned for creating or resolving lazy imports. Thisfeature is designed as a purely Python-facing mechanism, as C extensionstypically need immediate access to modules and cannot benefit from deferredloading. Existing C API functions likePyImport_ImportModule() remainunchanged and continue to perform eager imports. If compelling use cases emerge,this could be revisited in future versions.
Here are some alternative design decisions that were considered during thedevelopment of this PEP. While the current proposal represents what we believeto be the best balance of simplicity, performance, and maintainability, thesealternatives offer different trade-offs that may be valuable for implementersto consider or for future refinements.
Instead of updating the internal dict object to directly add the fields neededto support lazy imports, we could create a subclass of the dict object to beused specifically for Lazy Import enablement. This would still be a leakyabstraction though - methods can be called directly such asdict.__getitem__ and it would impact the performance of globals lookup inthe interpreter.
For this PEP, we decided to proposelazy for the explicit keyword as itfelt the most familiar to those already focused on optimizing import overhead.We also considered a variety of other options to support explicit lazyimports. The most compelling alternates weredefer anddelay.
Changingimport to be lazy by default is outside of the scope of this PEP.From the discussion onPEP 690 it is clear that this is a fairlycontentious idea, although perhaps once we have wide-spread use of lazyimports this can be reconsidered.
__lazy_modules__=["*"] as built-in syntaxThe suggestion to support__lazy_modules__=["*"] as a convenient way tomake all imports in a module lazy without explicit enumeration has beenconsidered. This approach was rejected because__lazy_modules__ alreadyrepresents implicit action-at-a-distance behavior that is tolerated solely as abackwards compatibility mechanism. Extending support to wildcard patterns wouldsignificantly increase implementation complexity and invite scope creep intopattern matching and globbing functionality. As__lazy_modules__ is apermanent language feature that cannot be removed in future versions, the designprioritizes minimalism and restricts its scope to serving as a transitional toolfor backwards compatibility.
It is worth noting that the implementation performs membership checks by calling__contains__ on the__lazy_modules__ object. Consequently, usersrequiring wildcard behavior may provide a custom object implementing__contains__ to returnTrue for all queries or other desired patterns.This design provides the necessary flexibility for advanced use cases whilemaintaining a simple, focused specification for the primary mechanism. If this PEP is accepted, addingsuch helper objects to the standard library can be discussed in a future issue. Presently, it is out of scope for this PEP.
with blocksAn earlier version of this PEP proposed disallowinglazyimport statementsinsidewith blocks, similar to the restriction ontry blocks. Theconcern was that certain context managers (likecontextlib.suppress(ImportError))could suppress import errors in confusing ways when combined with lazy imports.
However, this restriction was rejected becausewith statements have muchbroader semantics thantry/except blocks. Whiletry/except is explicitlyabout catching exceptions,with blocks are commonly used for resourcemanagement, temporary state changes, or scoping – contexts where lazy importswork perfectly fine. Thelazyimport syntax is explicit enough thatdevelopers who write it inside awith block are making an intentional choice,aligning with Python’s “consenting adults” philosophy. For genuinely problematiccases likewithsuppress(ImportError):lazyimportfoo, static analysistools and linters are better suited to catch these patterns than hard languagerestrictions.
with blocks under the global flagAnother rejected idea was to make imports insidewith blocks remain eagereven when the global lazy imports flag is set to"all". The rationale wasto be conservative: sincewith statements can affect how imports behave(e.g., by modifyingsys.path or suppressing exceptions), forcing imports toremain eager could prevent subtle bugs. However, this would create inconsistentbehavior wherelazyimport is allowed explicitly inwith blocks, butnormal imports remain eager when the global flag is enabled. This inconsistencybetween explicit and implicit laziness is confusing and hard to explain.
The simpler, more consistent rule is that the global flag affects importseverywhere that explicitlazyimport syntax is allowed. This avoids havingthree different sets of rules (explicit syntax, global flag behavior, and filtermechanism) and instead provides two: explicit syntax rules match what the globalflag affects, and the filter mechanism provides escape hatches for edge cases.For users who need fine-grained control, the filter mechanism(sys.set_lazy_imports_filter()) already provides a way to exclude specificimports or patterns. Additionally, there’s no inverse operation: if the globalflag forces imports eager inwith blocks but a user wants them lazy, there’sno way to override it, creating an asymmetry.
In summary: imports inwith blocks behave consistently whether markedexplicitly withlazyimport or implicitly via the global flag, creating asimple rule that’s easy to explain and reason about.
The initial PEP for lazy imports (PEP 690) relied heavily on the modificationof the internal dict object to support lazy imports. We recognize that thisdata structure is highly tuned, heavily used across the codebase, and veryperformance sensitive. Because of the importance of this data structure andthe desire to keep the implementation of lazy imports encapsulated from userswho may have no interest in the feature, we’ve decided to invest in analternate approach.
The dictionary is the foundational data structure in Python. Every object’sattributes are stored in a dict, and dicts are used throughout the runtime fornamespaces, keyword arguments, and more. Adding any kind of hook or specialbehavior to dicts to support lazy imports would:
Past decisions that violated this principle of keeping core abstractions cleanhave caused significant pain in the CPython ecosystem, making optimizationdifficult and introducing subtle bugs.
__class__ mutationAn alternative implementation approach was proposed where lazy import objectswould be transformed into their final form by mutating their internal state,rather than replacing the object entirely. Under this approach, a lazy objectwould be transformed in-place after the actual import completes.
This approach was rejected for several reasons:
from statements. When auser writeslazyfromfooimportbar, the objectbar could be anyPython object (a function, class, constant, etc.), not just a module. Anytransformation approach would require that the lazy proxy object havecompatible memory layout and other considerations with the target object,which is impossible to know before loading the module. This creates afundamental asymmetry wherelazyimportx andlazyfromximportywould require completely different implementation strategies, with the latterstill needing the proxy replacement mechanism.sys.modules that inheritfrom or replace the standard module type. These custom module classes canhave different memory layouts and sizes thanPyModuleObject. Thetransformation approach cannot work with such generic custom moduleimplementations, creating fragility and maintenance burden across theecosystem.PyObject_TypeCheck. The transformation also requirescareful coordination between the lazy import machinery and the type system toensure that the object remains valid throughout the transformation process.The current proxy-based design avoids these issues by maintaining clearboundaries between the lazy proxy and the actual imported object.The current design, which uses object replacement through theLazyImportTypeproxy pattern, provides a consistent mechanism that works uniformly for bothimport andfrom...import statements while maintaining cleanerseparation between the lazy import machinery and Python’s core object model.
lazy imports find the module without loading itThe Pythonimport machinery separates out finding a module and loadingit, and the lazy import implementation could technically defer only theloading part. However, this approach was rejected for several critical reasons.
A significant part of the performance win comes from skipping the finding phase.The issue is particularly acute on NFS-backed filesystems and distributedstorage, where eachstat() call incurs network latency. In these kinds ofenvironments,stat() calls can take tens to hundreds of millisecondsdepending on network conditions. With dozens of imports each doing multiplefilesystem checks traversingsys.path, the time spent finding modulesbefore executing any Python code can become substantial. In some measurements,spec finding accounts for the majority of total import time. Skipping only theloading phase would leave most of the performance problem unsolved.
More critically, separating finding from loading creates the worst of bothworlds for error handling. Some exceptions from the import machinery (e.g.,ImportError from a missing module, path resolution failures,ModuleNotFoundError) would be raised at thelazyimport statement, whileothers (e.g.,SyntaxError,ImportError from circular imports, attributeerrors fromfrommoduleimportname) would be raised later at first use.This split is both confusing and unpredictable: developers would need tounderstand the internal import machinery to know which errors happen when. Thecurrent design is simpler: with full lazy imports, all import-related errorsoccur at first use, making the behavior consistent and predictable.
Additionally, there are technical limitations: finding the module does notguarantee the import will succeed, nor even that it will not raise ImportError.Finding modules in packages requires that those packages are loaded, so itwould only help with lazy loading one level of a package hierarchy. Since“finding” attributes in modulesrequires loading them, this would create ahard to explain difference betweenfrompackageimportmodule andfrommoduleimportfunction.
lazy keyword in the middle of from importsWhile we foundfromfoolazyimportbar to be a really intuitive placementfor the new explicit syntax, we quickly learned that placing thelazykeyword here is already syntactically allowed in Python. This is becausefrom.lazyimportbar is legal syntax (because whitespace does notmatter.)
lazy keyword at the end of import statementsWe discussed appending lazy to the end of import statements like suchimportfoolazy orfromfooimportbar,bazlazy but ultimately decided thatthis approach provided less clarity. For example, if multiple modules areimported in a single statement, it is unclear if the lazy binding applies toall of the imported objects or just a subset of the items.
eager keywordSince we’re not changing the default behavior, and we don’t want toencourage use of the global flags, it’s too early to consider addingsuperfluous syntax for the common, default case. It would create too muchconfusion about what the default is, or when theeager keyword would benecessary, or whether it affects lazy importsin the explicitly eagerlyimported module.
As lazy imports allow some forms of circular imports that would otherwisefail, as an intentional and desirable thing (especially for typing-relatedimports), there was a suggestion to add a way to override the global disableand force particular imports to be lazy, for instance by calling the lazyimports filter even if lazy imports are globally disabled.
This approach could introduce a complex hierarchy of the different “override”systems, making it much harder to analyze and reason about the code.Additionally, this may require additional complexity to introduce finer-grainedsystems to enable or disable particular imports as the use of lazy importsevolves. The global disable is not expected to see commonplace use, but be moreof a debugging and selective testing tool for those who want to tightly controltheir dependency on lazy imports. We think it’s reasonable for packagemaintainers, as they update packages to adopt lazy imports, to decide tonot support running with lazy imports globally disabled.
It may be that this means that in time, as more and more packages embraceboth typing and lazy imports, the global disable becomes mostly unused andunusable. Similar things have happened in the past with other global flags,and given the low cost of the flag this seems acceptable. It’s also easierto add more specific re-enabling mechanisms later, when we have a clearerpicture of real-world use and patterns, than it is to remove a hastily addedmechanism that isn’t quite right.
The global activation and filter functions (sys.set_lazy_imports,sys.set_lazy_imports_filter,sys.get_lazy_imports_filter) could bemarked as “private” or “advanced” by using underscore prefixes (e.g.,sys._set_lazy_imports_filter). This was rejected because branding asadvanced features through documentation is sufficient. These functions havelegitimate use cases for advanced users, particularly operators of largedeployments. Providing an official mechanism prevents divergence from upstreamCPython. The global mode is intentionally documented as an advanced feature foroperators running huge fleets, not for day-to-day users or libraries. Pythonhas precedent for advanced features that remain public APIs without underscoreprefixes - for example,gc.disable(),gc.get_objects(), andgc.set_threshold() are advanced features that can cause issues if misused,yet they are not underscore-prefixed.
A decorator-based syntax could mark imports as lazy:
@lazyimportjson@lazyfromfooimportbar
This approach was rejected because it introduces too many open questions andcomplications. Decorators in Python are designed to wrap and transform callableobjects (functions, classes, methods), not statements. Allowing decorators onimport statements would open the door to many other potential statementdecorators (@cached,@traced,@deprecated, etc.), significantlyexpanding the language’s syntax in ways we don’t want to explore. Furthermore,this raises the question of where such decorators would come from: they wouldneed to be either imported or built-in, creating a bootstrapping problem forimport-related decorators. This is far more speculative and generic than thefocusedlazyimport syntax.
A backward compatible syntax, for example in the form of a context manager,has been proposed:
withlazy_imports(...):importjson
This would replace the need for__lazy_modules__, and allowlibraries to use one of the existing lazy imports implementations in olderPython versions. However, adding magicwith statements with that kind ofeffect would be a significant change to Python andwith statements ingeneral, and it would not be easy to combine with the implementation forlazy imports in this proposal. Adding standard library support for existinglazy importerswithout changes to the implementation amounts to the statusquo, and does not solve the performance and usability issues with thoseexisting solutions.
globals()An alternative to reifying onglobals() or exposing lazy objects would beto return a proxy dictionary that automatically reifies lazy objects whenthey’re accessed through the proxy. This would seemingly give the best of bothworlds:globals() returns immediately without reification cost, butaccessing items through the result would automatically resolve lazy imports.
However, this approach is fundamentally incompatible with howglobals() isused in practice. Many standard library functions and built-ins expectglobals() to return a realdict object, not a proxy:
exec(code,globals()) requires a real dict.eval(expr,globals()) requires a real dict.type(globals())isdict would break..update() would need special handling.The proxy would need to be so transparent that it would be indistinguishablefrom a real dict in almost all cases, which is extremely difficult to achievecorrectly. Any deviation from true dict behavior would be a source of subtlebugs.
__dict__ orglobals() accessThree options were considered for howglobals() andmod.__dict__ shouldbehave with lazy imports:
globals() ormod.__dict__ traverses and resolves all lazyobjects before returning.globals() ormod.__dict__ returns the dictionary with lazyobjects present (chosen).globals() returns the dictionary with lazy objects, butmod.__dict__ reifies everything.We chose option 2: bothglobals() and__dict__ return the rawnamespace dictionary without triggering reification. This provides a clean,predictable model where low-level introspection APIs don’t trigger sideeffects.
Havingglobals() and__dict__ behave identically creates symmetry anda simple mental model: both expose the raw namespace view. Low-levelintrospection APIs should not automatically trigger imports, which would besurprising and potentially expensive. Real-world experience implementing lazyimports in the standard library (such as the traceback module) showed thatautomatic reification on__dict__ access was cumbersome and forcedintrospection code to load modules it was only examining.
Option 1 (always reifying) was rejected because it would makeglobals()and__dict__ access surprisingly expensive and prevent introspecting thelazy state of a module. Option 3 was initially considered to “protect” externalcode from seeing lazy objects, but real-world usage showed this created moreproblems than it solved, particularly for stdlib code that needs to introspectmodules without triggering side effects.
We would like to thank Paul Ganssle, Yury Selivanov, Łukasz Langa, LysandrosNikolaou, Pradyun Gedam, Mark Shannon, Hana Joo and the Python Google team,the Python team(s) @ Meta, the Python @ HRT team, the Bloomberg Python team,the Scientific Python community, everyone who participated in the initialdiscussion ofPEP 690, and many others who provided valuable feedback andinsights that helped shape this PEP.
This document is placed in the public domain or under theCC0-1.0-Universal license, whichever is more permissive.
Source:https://github.com/python/peps/blob/main/peps/pep-0810.rst
Last modified:2025-11-07 04:32:09 GMT