Python 3.15 lazy imports: how to tell if a module has run without running it

Python 3.15's lazy imports defer a module until first use, which makes almost any probe of the module a use. Here is why hasattr() fails and the PEP 810 bookkeeping that answers the question safely.

Binary Buddies · 5 min read · 1 view
A sealed crate waiting under a single lampA warehouse at night with shelves of sealed boxes and one unopened crate on the floor caught in a beam of lamplight, dust rising through the light.

Python 3.15 lets you write lazy import example and get a name bound without the module running. That raises a testing problem as soon as you try to verify it: how do you prove the module has not executed yet, when almost every way of looking at it makes it execute?

A Stack Overflow question set this up with two files. example.py prints Module executed and sets value = 42. main.py does lazy import example and then asks hasattr(example, 'value'). The probe answers the question by destroying it: the lookup forces the import, so the module's print fires inside the check meant to detect it.

The fix, confirmed in an answer quoting PEP 810, is to stop looking at the module object at all and look at the interpreter's bookkeeping instead: 'example' in sys.modules.

Key takeaways

  • A lazily imported module is listed in sys.lazy_modules and is absent from sys.modules until it is used.
  • Using it, which PEP 810 calls reification, runs the module, removes its name from sys.lazy_modules and puts it in sys.modules.
  • hasattr(), getattr() and plain attribute access all reify, so none of them can tell you whether the module has run.
  • 'example' in sys.modules is the check that answers the question without forcing the import.

What a lazy import leaves behind

When an import is marked lazy, PEP 810 says Python calls __lazy_import__ instead of __import__. That call adds the module's fully qualified name to sys.lazy_modules, a set of names. What it does not do is create an entry in sys.modules: the PEP is explicit that lazy module objects do not appear there and are only listed in the lazy set.

The lazy object itself, under normal operation, lives in a global variable. The common ways of reaching that variable, ordinary name access and module attribute access, resolve the import and swap the lazy object for the real module. The PEP calls this reification: resolving the import at that point in the program and replacing the placeholder with the concrete module. At that moment the name leaves sys.lazy_modules and the module, now fully executed, sits in sys.modules like any eagerly imported one.

Lifecycle of a lazy importA lazy import lists the module in sys.lazy_modules; when an attribute is accessed it is reified and moves to sys.modules.lazy import exampleListed in sys.lazy_modulesexample.value is accessedReified: in sys.modules
The module only reaches sys.modules after something uses it; until then its name waits in sys.lazy_modules.

Why hasattr() can never say no

PEP 810 states that accessing a lazy import through normal attribute access or getattr() triggers reification of the accessed attribute. Its own illustration uses the standard library's json:

lazy import json
# json not in sys.modules yet

dumps_func = json.dumps                # reifies
dumps_func = getattr(json, 'dumps')    # also reifies

# json is now in sys.modules

hasattr() does its work by performing the same kind of attribute lookup as getattr(), which is why the Stack Overflow answer rules it out. There is a second problem underneath: the PEP lists regular variable access among the paths that resolve a lazy import, so merely naming example in an expression counts as using it. A probe built on the module object reifies the module before it can report anything, so it will only ever answer that the module has run.

This is the design working as intended rather than a quirk. Lazy imports promise that the first real use pays the import cost, and a lookup is a real use. The question has to be asked somewhere the lazy object does not live.

A line of lanterns at dusk with only one litA wire strung between two poles over rolling hills carries six lanterns; five hang dark and one glows.

The check that works: sys.modules

The Stack Overflow asker had already guessed at 'example' in sys.modules and was unsure whether it was reliable. The answer's verdict: yes, that is the correct way to check whether the module has executed. It never touches the lazy object, only the dictionary of loaded modules keyed by name, so there is nothing for Python to reify. The answer demonstrates the full transition with two assertions:

import sys
lazy import example

# still lazy
assert 'example' in sys.lazy_modules and 'example' not in sys.modules

example.value  # attribute access forces reification

# fully reified
assert 'example' not in sys.lazy_modules and 'example' in sys.modules

Read together, the two sets give a three-way answer. Present in sys.lazy_modules means imported lazily and still waiting. Present in sys.modules means executed. In neither means the name was never imported this way at all. For the original question, has the module run yet, sys.modules alone is enough, and the Module executed line stays silent while you ask.

Where the module name appearsBefore access the name is listed in sys.lazy_modules and absent from sys.modules; after access it is absent from sys.lazy_modules and listed in sys.modules.Where the name appearssys.lazy_modulessys.modulesBefore accessAfter accesslistedabsentabsentlisted
The two sets swap places at reification, so membership in sys.modules is the signal that the module has run.

What counts as touching the module

The useful rule from PEP 810 is about where you look, not which function you call. Anything that goes through the variable bound by lazy import, whether a bare attribute, getattr() or hasattr(), is a use and pays the import. Anything that asks sys about the module's name is bookkeeping and leaves it alone.

There is one deliberate exception on the other side of the line. The PEP says calling dir() on a module is special-cased in mod.__dir__ to avoid reification, so listing a module's names does not by itself force the lazy imports it holds.

Operations that reify versus those that do notAttribute access, getattr and hasattr on the lazy module trigger reification; membership checks on sys.modules and sys.lazy_modules and dir() on a module do not.Triggers reificationLeaves it lazyexample.valuegetattr(example, 'value')hasattr(example, 'value')'example' in sys.modules'example' in sys.lazy_modulesdir() on a module
Everything on the left goes through the lazy object and runs the module; everything on the right asks the interpreter instead.

Limits and open questions

Both sets describe the module name, not each reference to it. PEP 810 says a module is removed from sys.lazy_modules when it is reified even if other unreified lazy references to it still exist. So 'example' in sys.modules tells you the module has executed; it does not tell you that every variable which lazily imported it has had its placeholder swapped for the real object yet.

The PEP also describes the normal behaviour with a hedge: lazy objects should only end up in global variables under normal operation. Code that digs placeholders out through unusual routes sits outside that description, so test any such pattern on your own 3.15 interpreter before building on it. And because the behaviour here is quoted from PEP 810 itself, check the final Python 3.15 documentation for sys.lazy_modules before relying on its exact semantics in production tests.

Does hasattr() trigger a lazy import in Python 3.15?

Yes. It performs an attribute lookup like getattr(), and PEP 810 says attribute access and getattr() reify the lazy module, so the module runs during the check.

Is checking sys.modules safe with lazy imports?

Yes. Lazy module objects are not placed in sys.modules; the module appears there only once it has been reified, and the membership test never touches the lazy object.

What is reification?

PEP 810's term for resolving a lazy import at the point of first use and replacing the lazy object with the real, executed module.

What is sys.lazy_modules?

A set of fully qualified names of modules imported lazily. A name is removed from it when the module is reified.

This article draws on How can I check whether a lazy import has executed in Python 3.15 without triggering it? and an answer to it, on the Stack Exchange network, licensed under CC BY-SA 4.0.

  • #PEP 810
  • #Python
  • #lazy-loading
  • #python-import
  • #sys.lazy_modules
  • #sys.modules

Comments

Add a comment
Sign in with Google to join the conversation