A top-level import is often doing more work than its caller needs. A command-line program may import an optional client, a report renderer, or an SDK before it has parsed the subcommand that would use it. That work is visible as startup latency, but moving imports into functions can make dependency ownership difficult to see and can postpone errors in ways that are hard to test.
Python 3.15 adds explicit lazy imports through PEP 810. A lazy import statement keeps the normal, module-level declaration but defers loading the named module until code uses it. That makes it useful for a narrow startup boundary, not a switch to apply across an application without tests. An import that registers a plugin, changes process state, reads configuration, or is needed to make a module importable has an observable time boundary. Moving that boundary can change behavior even when the imported module itself is correct.
This guide uses Python 3.15.0rc2 in a disposable local build to test that boundary. The source archive’s SHA-256 was checked against the value published on python.org before it was built. A small fixture that writes a marker during module initialization left the marker absent after lazy import; accessing one module attribute created it. A deliberately missing lazy import printed a line before access, then failed at the access with ModuleNotFoundError. Those two results make the important operational point concrete: lazy imports defer both module initialization and an import failure.
Python 3.15.0rc2 is a release candidate, scheduled ahead of the 3.15.0 final release. Do not replace a production interpreter with a preview build for this test. Use the release candidate only in an isolated compatibility environment, then repeat the test against the final interpreter and the application’s supported dependencies before adopting the syntax.
Choose a boundary that is actually optional
Begin with an import that is not needed on every execution path. A subcommand-specific formatter, an optional cloud SDK, or a rarely used export driver is a better candidate than a module that establishes global logging, registers entry points, reads mandatory settings, or supplies classes needed during normal startup.
The first question is not whether an import is slow. It is whether deferring it preserves the program’s contract. For a command-line tool, write down which invocation first needs the module. For a web service, identify which request or background task would first touch it. That tells you where an error will surface after the change and which health check must exercise it.
Keep the scope small at first. A single explicit lazy import is reviewable because the deferred boundary appears next to the dependency. Enabling lazy behavior broadly can pull many unrelated modules across the same boundary and makes a regression harder to attribute. The Python documentation provides -X lazy_imports and PYTHON_LAZY_IMPORTS modes, but a targeted test should start with the statement-level syntax.
Lazy imports are allowed at module scope. They are not valid inside functions, class bodies, or try, except, or finally blocks. Star imports and future imports cannot be lazy. Those restrictions are useful: they prevent a lazy declaration from being hidden inside a local control path while making the startup contract less obvious to the reader.
Build a disposable interpreter
Use a temporary working directory and the exact source version under evaluation. The Python 3.15.0rc2 release page publishes an XZ source archive and its SHA-256 value. Verify the archive before extracting or executing build scripts.
work=$(mktemp -d)
cd "$work"
curl --fail --location --remote-name \
https://www.python.org/ftp/python/3.15.0/Python-3.15.0rc2.tar.xz
printf '%s %s\n' \
'8d93af5eaaaea5adfd41bd786a7ba3f03f2ad1ab57c6a65e0b963deab91d5ad7' \
Python-3.15.0rc2.tar.xz | sha256sum -c
tar -xf Python-3.15.0rc2.tar.xz
cd Python-3.15.0rc2
./configure --prefix="$work/install" --without-ensurepip
make -j"$(nproc)"
./python --version
The --prefix keeps a later make install separate from the system interpreter; this test does not need to install it. --without-ensurepip avoids downloading or bootstrapping packaging tools that are unrelated to the import experiment. A source build requires a compiler and the usual Python build dependencies, so use the organization’s existing package-management process if those prerequisites are not available. Do not add system packages merely to make a one-off test pass.
The local validation reported the following result after checksum verification:
Python 3.15.0rc2
Keep the downloaded archive, checksum result, interpreter version, and test transcript with the compatibility evidence rather than committing them to an application repository. They identify a release candidate and local build conditions, not a portable production result.
Prove that initialization is deferred
Create a fixture whose top-level code has one harmless, visible effect. The marker represents work that a real optional dependency might perform during import: reading metadata, building a registry, or importing more modules. It is deliberately local and disposable.
# lazy_fixture.py
from pathlib import Path
import os
marker = Path(os.environ["LAZY_MARKER"])
marker.write_text("loaded\n", encoding="utf-8")
value = 42
In a second file, declare the dependency lazily. Do not access lazy_fixture before checking the marker. Accessing value is the deliberate reification point: it forces Python to resolve the lazy object and execute the module.
# test_deferred.py
import os
from pathlib import Path
marker = Path(os.environ["LAZY_MARKER"])
lazy import lazy_fixture
print(f"before-access marker={marker.exists()}")
assert not marker.exists()
assert lazy_fixture.value == 42
print(f"after-access marker={marker.exists()} value={lazy_fixture.value}")
assert marker.read_text(encoding="utf-8") == "loaded\n"
Run the test with the built interpreter and an explicit module path:
LAZY_MARKER="$PWD/marker.txt" PYTHONPATH="$PWD" \
./python test_deferred.py
The local run produced this output:
before-access marker=False
after-access marker=True value=42
That before-and-after check is more useful than a timing number for the first migration. It proves that the declaration did not initialize the fixture, while the first attribute access did. If a proposed application change cannot produce an equivalent observable boundary, it is not yet clear what work would move or how to test it.
Test the delayed error path
A lazy import can also delay an error that an eager import would have raised during process startup. That is desirable only if the optional path has a clear diagnostic and the application can remain useful without it. Test the failure deliberately rather than discovering it when an operator invokes a rare command.
# test_deferred_error.py
lazy import does_not_exist
print("before-access")
print(does_not_exist.value)
Run it with the same interpreter:
PYTHONPATH="$PWD" ./python test_deferred_error.py
The test printed before-access and then exited nonzero when the attribute access resolved the missing module. The traceback identified the lazy import and ended with ModuleNotFoundError: No module named 'does_not_exist'. Treat that result as a contract: the optional command, endpoint, or task that first needs a lazy dependency must catch and report an expected dependency error where appropriate. Do not turn a missing required dependency into a later, less actionable failure merely to improve startup time.
For an application dependency, add one test that invokes a path which does not need the module and another that invokes the first path which does. The first should prove that ordinary startup remains usable; the second should prove that the optional feature loads and produces its expected result. Add a negative case only when the product has a supported diagnostic for an unavailable optional dependency.
Keep production changes narrow
After the fixture works, replace it with one candidate dependency in a branch or disposable environment. Record the interpreter version, the dependency version, the normal path tested, and the first-use path tested. Import-time side effects are the main review item. Modules that configure logging, register plugins, mutate a registry, install import hooks, or validate required configuration can be unsafe to defer even when their import cost is measurable.
Do not use a global lazy-import setting as a performance shortcut until the application test suite demonstrates that its import graph is compatible. Explicit declarations make the rollout reversible: replacing lazy import package with import package restores eager behavior without changing callers. If startup improvement is not measurable or the delayed boundary adds operational complexity, leave the import eager.
The small marker test establishes the behavior to preserve during that decision. In the Python 3.15.0rc2 validation, initialization and the missing-module exception both moved to first use. That is a practical reason to test the first-use path before relying on lazy imports in an operational tool.