Explicit Lazy Imports for Python

5 hours ago 1

By Jake Edge
October 20, 2025

Importing modules in Python is ubiquitous; most Python programs start with at least a few import statements. But the performance impact of those imports can be large—and may be entirely wasted effort if the symbols imported end up being unused. There are multiple ways to lazily import modules, including one in the standard library, but none of them are part of the Python language itself. That may soon change, if the recently proposed PEP 810 ("Explicit lazy imports") is approved.

Consider a Python command-line tool with multiple options, some of which require particular imports that others do not need; then a user invokes it with --help and has to wait for all of those imports to load before they see the simple usage text. Once they decide which option they were after, they have to wait again for the imports before the tool performs the operation they wanted. What if, instead, those imports could be delayed until they were actually needed in the Python code? That is the basic idea behind lazy imports.

Our last look at the idea was in December 2022, just after the steering council (SC) rejected a PEP for lazy imports because of concerns it would fracture the ecosystem. Unlike the current proposal, PEP 690 ("Lazy Imports") switched importation to be lazy by default. In the decision announcement, the SC saw problems with the feature because "it becomes a split in the community over how imports work", which leads to a need to test code "in both traditional and lazy import setups". But the SC did see the value in the underlying idea:

A world in which Python only supported imports behaving in a lazy manner would likely be great. But we cannot rewrite history and make that happen. As we do not envision the Python [language] transitioning to a world where lazy imports are the default, let alone only, import behavior. Thus introducing this concept would add complexity to our ecosystem.

Explicit, not implicit

Along the way, various suggestions had been made for a more explicit version of lazy imports, rather than defaulting to lazy and requiring an opt-out for the traditional behavior. After the rejection, one of the authors of PEP 690, Carl Meyer, asked if there was interest in a more explicit version; there was interest, and more discussion, but it ultimately did not go anywhere until now. The other author of PEP 690, Germán Méndez Bravo, joined with a long list of authors, notably including SC member Pablo Galindo Salgado, to create PEP 810, which adds the following syntax:

lazy import foo lazy from foo import bar

The lazy keyword is soft, which means that it can be used in any other context (in particular, as a variable, function, or class name) without confusion. There are restrictions on lazy imports, though; for one thing, the wildcard import:

from foo import *

cannot be done lazily, so putting

lazy

in front of that is an error. There are also multiple contexts where lazy imports cannot be specified; for one, it is only allowed at the global (i.e. module) level. The

lazy

keyword cannot be used inside functions or classes, in

try

/

except

blocks, or for

from __future__

imports. Originally, it was also disallowed inside

with

blocks, but some of the feedback in the lengthy discussion thread caused that to change, as we will see.

The staff here at LWN.net really appreciate the subscribers who make our work possible. Is there a chance we could interest you in becoming one of them?

The lazy keyword only makes the import potentially lazy; there are some mechanisms, including a global flag and a lazy-imports filter, that can make the interpreter ignore the keyword, which turns the statements into regular, non-lazy (or "eager") imports. The flag, which can be set on the command line, in an environment variable, or with the sys.set_lazy_imports() function, has three settings. If it is set to "normal" (or is unset), only lazy imports are handled lazily; "none" disables lazy imports entirely, while "all" makes every module-level import (except for import *, or in try blocks) lazy.

If an import is lazy, the names being imported are bound to lazy proxy objects that are only instantiated when they are referred to. For example:

lazy import abc # abc is now bound to a lazy proxy lazy from foo import bar, baz # foo, bar, baz all proxies abc.def() # loads module abc bar() # resolves bar, which loads foo, baz still proxy baz() # resolves baz, does not reload foo

Beyond the explicit keyword, a module can have a __lazy_modules__ variable containing a list of module names (as strings); those modules will be treated as if the lazy keyword was applied when they are encountered in an import statement. It is meant to be used for code that may run on versions of Python that lack support for lazy, since setting __lazy_modules__ will have no effect on those versions, while using lazy would be a syntax error.

The process of turning a lazy proxy object into a concrete object is known as "reification". It uses the standard Python import machinery to import a module, which may effectively be a no-op if it has already been imported in the meantime, and to assign a module object to the name in sys.modules. If a symbol from the module is being referenced (e.g. bar() above), the symbol from the imported module is bound to the corresponding global variable (bar) in the importing module. If the import fails, or the symbol is not present, the usual exception (e.g. ImportError) is raised at the site where the reification happens, but with additional traceback information about the site of the lazy-import statement added in.

The truly massive PEP has a great deal of detail on the semantics, reference implementation, and backward-compatibility considerations for the feature. Due to the explicit nature of the opt-in, problems with backward compatibility are likely to be minimal. The timing of when import errors are reported might cause some problems, but if an existing import is not triggering an error, adding lazy should not change that in any way. Another thing to keep in mind is that reification is done using the state of the import machinery (e.g. various sys.path* variables and the __import__() function) at that time, not what it was at the time the lazy import was evaluated.

Discussion

Galindo Salgado announced the PEP and opened the discussion about it on October 3. Overall, the reaction has been quite positive, which is not much of a surprise given that the previous PEP was popular. Reducing startup time for applications is something that many developers have worked on—to the point that some organizations have either forked CPython or are patching their version to support some mechanism for lazy imports. For example, Meta has lazy imports in its Cinder fork of CPython; back in 2022, Méndez Bravo wrote an extensive look at the path to making lazy imports work for the Instagram code base using Cinder.

Part of the reasoning behind reviving the lazy-imports effort is to give companies like Meta (and others currently using lazy imports via internal changes to CPython) an "official, supported mechanism" so that "they can experiment and deploy without diverging from upstream CPython", Galindo Salgado said in the discussion thread.

Several people expressed concern about the global "all" flag that makes all imports lazy; for example, Adam Turner said:

I worry that this will become a hidden secret hack ™ that will proliferate as a way to easily increase performance with zero other changes, etc. This means that I as a [library] author would potentially be liable for an influx of bug reports that my library doesn't work with lazy imports, even though I haven't tested for or intend to use them.

The flag is only meant for advanced users who are willing and able to test their applications under those circumstances, Galindo Salgado said (note that the flag value changed from "enabled" to "all" along the way):

Docs will make the trade-offs clear: if you enable the global mode, you're expected to use the filter and own exclusions. It's fine for maintainers to close reports that only fail under -X lazy_imports=enabled without a minimal repro using explicit lazy. This is not really a problem: maintainers can simply decide what they support. If users open issues saying "your library could support this better," that's just information: you can close it if you don't want to invest, or act on it if you do. As a maintainer myself, I don't see harm in users surfacing that feedback, and we'll make sure the docs hammer home that enabling the flag means you accept those trade-offs.

Lazy importing already exists for Python in various forms, including in the LazyLoader that is part of importlib in the standard library; there are other options in the Python Package Index (PyPI) as well. So library maintainers likely have been dealing with users reporting lazy-import problems, though it may well not have been a common complaint.

There are certain applications, like pip, that need to ensure their imports are done eagerly, as Damian Shaw pointed out: "Pip has to import [its] modules eagerly so that installing a wheel doesn't allow the wheel to insert itself into the pip namespace and run arbitrary code as part of the install step". He wondered if those imports should be done inside a "with contextlib.nullcontext()" block, which would cause the imports to be eager since they are in a do-nothing (nullcontext()) with block (though that has changed since this exchange). That would work, Galindo Salgado said, but a simpler solution would be for pip to explicitly disable lazy imports for itself with sys.set_lazy_imports("none"); the security implications section of the PEP was updated with that information as a result of the exchange.

Anticipating ImportError

There is a fairly common pattern in Python programs to switch to a different module when an import is not found, but it uses a try block to do so. Daniel F Moisset asked about a lazy alternative for something like:

try: from typing import LiteralString except ImportError: LiteralString = str

Will Shanks suggested explicitly reifying the import at the point where it is about to be used, in order catch the exception, but one of the PEP authors, Brittany Reynoso, pointed out that just using the module in the usual way (perhaps wrapped in a

try

block catching

ImportError

) will reify it if needed. Oscar Benjamin had a somewhat different formulation:

What I want is something like: try: lazy import numpy using_numpy = True except ImportError: using_numpy = False # Here numpy is still only lazily imported Obviously this requires some part of the import machinery to at least access the filesystem so it could not be a completely lazy import.

"Thanos" suggested using importlib.util.find_spec('numpy') to determine if numpy can be found, which works, but is not a guarantee that actually doing the import will not raise an exception.

Brett Cannon, developer of importlib and LazyLoader, noted that a "key benefit of this PEP over LazyLoader" is that "it makes finding the module lazy (LazyLoader is eager in finding the module but lazy for loading)". But Alyssa Coghlan thought that the PEP needed to justify that choice "as it's what rules out using lazy imports for cheap existence checking". She did not necessarily want to see a change in semantics for the feature, but hoped for something to be added to the "Rejected Ideas" section of the PEP.

In a multi-part update of the PEP, Thomas Wouters, another of the seven PEP authors, added a justification for doing all of the module resolution at reification time. The concerns are that finding a module can often be a significant part of the performance cost of an import, especially on network filesystems. In addition, exceptions would be raised at different times for different kinds of problems, which could be confusing. "The current design is simpler: with full lazy imports, all import-related errors occur at first use, making the behavior consistent and predictable."

Interaction with types

One of the benefits of lazy imports is that they can be used to avoid importing typing information at run time; since the advent of static types for Python, an enormous ecosystem for typing has sprung up, nearly all of which is only needed when a static typechecker is being used—not at run time. The "FAQ" section of the PEP notes that the common pattern for avoiding the import of type annotations at run time can be switched to a lazy import:

from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Sequence, Mapping def process(items: Sequence[str]) -> Mapping[str, int]: ... # could instead be: lazy from collections.abc import Sequence, Mapping # No runtime cost def process(items: Sequence[str]) -> Mapping[str, int]: ...

The PEP also mentions that possibility of

automatically making "if TYPE_CHECKING" imports lazy

down the road.

As David Ellis pointed out, though, lazy imports can be used to avoid problems with circular imports; that only works if those imports remain lazy, which will not happen if lazy imports are disabled using the flag. He wondered if there was a need to add a filter that is analogous to the lazy-imports filter but forcefully opts into lazy for some modules. In the message linked above, Wouters said the PEP authors preferred not providing another filter:

And yes, it is intentional that disabling lazy imports globally would expose import cycles that only work with lazy imports. The advice for import cycles remains pretty much the same, even with lazy imports: refactor the code so you don't have an unresolvable cycle.

Benjamin did not agree with Wouters's refactoring suggestion, due to circular typing imports, which are not uncommon. Jon Harding expanded on that, noting: "Library authors will (perhaps unintentionally) create circular references, only to later get bug reports from those (presumably) rare users that disable lazy imports." In general, there is an asymmetry in the proposal:

The PEP discusses how lazy imports become potentially lazy during evaluation. Have the PEP authors contemplated an analogous potentially eager state that eagerly reifies lazy imports except for those which reference partially imported modules?

Galindo Salgado said that the authors believe that using the flag, either to enable or disable lazy imports, is an advanced feature; its users have to take responsibility for any breakage experienced. Wouters agreed and added another rejected-ideas entry; "We think it's reasonable for package maintainers, as they update packages to adopt lazy imports, to decide to not support running with lazy imports globally disabled."

with

Using a try/except block with a lazy import could easily lead to confusion, since normally the intent is clearly to catch import problems in the block, but they won't actually occur until later. Various ideas about changing import exception handling were discussed, but the PEP authors have "decided to forbid them", Galindo Salgado said in a post trying to narrow down the discussion some. At that point, the thread had gone well over 200 posts in less than a week. The authors are "taking the approach of nailing the core feature set first and then allowing people to build on top of it later" he emphasized.

One area that the authors were still soliciting input on was the question of lazy imports inside with blocks. Paul Ganssle had a lengthy post describing a backward-compatibility strategy for modules that already have some form of lazy imports. Under the assumption that the lazy-imports feature was added for Python 3.15 (due in October 2026), existing library code does not have an easy path: "The __lazy_modules__ mechanism allows you to opt in to lazy semantics for 3.15, but I already have lazy semantics, and switching to __lazy_modules__ for Python <3.15 would be a regression for my users." He suggested using a context manager like lazy_imports from the eclectic utils (etils) package as way to bridge that gap:

__lazy_modules__ = ["foo", "bar"] from ._compat import lazy_importer with lazy_importer(__lazy_modules__): import foo from bar import blah

If lazy imports were allowed inside

with

, Python 3.15 and above could simply turn

lazy_importer()

into

contextlib.nullcontext()

and the

__lazy_modules__

would take care of handling

foo

and

bar

lazily. But with the PEP as it stands, that will not work. Furthermore:

The backwards compatibility use case only works if with statements are allowed from the beginning — if they are forbidden in 3.15 and then allowed in 3.16+, we are still stuck with a hack in 3.15.

Several people opted for simplicity, suggesting that lazy imports in with blocks could be added later. But Ganssle said that excluding with is actually complicating things:

I would argue that allowing lazy imports is the simpler option, since forbidding them requires a more complicated implementation and requires specifying things like "when a lazy import appears in a context manager using the __lazy_modules__ mechanism, it is not an error but rather imported eagerly".

He is referring to an example he gave in an earlier message. Specifying a module import as lazy using __lazy_modules__ leads to a potentially confusing situation:

__lazy_modules__ = ["foo", "bar"] import foo # lazy with blah(): import bar # eager

Some of the same arguments could be made with regard to

try

, but the intent is different; context managers can be used to suppress exceptions, but that usage is not widespread, whereas catching exceptions with

except

is ubiquitous. As Ganssle put it: "on Github I see about 10k uses of suppress(ImportError) and 3.5M uses of except ImportError". In addition, for a clean backward-compatibility picture using a context manager as he described,

with

would need to support lazy imports from the outset.

Those argument seem to have been sufficient, as the PEP authors decided to allow lazy imports in with blocks, Galindo Salgado said:

There are many legitimate use cases where with is used for managing lifetime or scoping behavior rather than just suppressing exceptions, and the syntax is explicit enough that users know what they're doing. It fits Python's "consenting adults" model as with carries broader semantics than just error handling. For the genuinely problematic cases (like contextlib.suppress(ImportError)), we think linters are the right abstraction to catch them rather than hard language restrictions.

Off to the SC

That led to a final PEP update, which was sent to the SC for its consideration on October 14. The short ten-day discussion period before SC submission surprised some, but Galindo Salgado assured commenters that the SC would not have time to start its review for a few weeks. That should give people more time to review it, but, after more than 300 comments, the authors "certainly feel it has been discussed enough for us to feel confident on the design".

There was, of course, the inevitable bikeshedding about names—"defer" was a popular replacement for lazy—and about the placement of the new keyword. There are quite a few who feel that from imports should look like:

from foo lazy import bar

The PEP addresses that—it is not missing much, if anything—by pointing out that the authors also preferred that form but found that it was already legal syntax. White space is not significant in the

from

statement, so:

from . lazy import bar # is equivalent to from .lazy import bar

Some felt that deprecating that corner case was worth it, while others saw having lazy imports always start with the

lazy

keyword as an advantage. That is typical when choosing a bikeshed color, of course.

One would guess that PEP 810 has an excellent chance, given that Galindo Salgado and Wouters were both on the SC that rejected PEP 690, thus presumably can shape something that will be acceptable this time around. Having something in the language itself will help libraries and applications converge on a single lazy-import implementation, rather than the hodge-podge of solutions in use today. In the unlikely event that the PEP is rejected outright, though, it will still have been a useful exercise as it is hard to imagine that any lazy-import feature will make the cut when both implicit (PEP 690) and explicit (PEP 810) approaches have failed. If the topic is raised again after that, it will be easy to provide good reason to not pursue the idea of lazy imports again.




Read Entire Article