Adopting Python 3.15's lazy imports: a 4× faster CLI, and where they didn't help
On November 3 we moved our back office to Python 3.15.2. It is one Python package with three front ends: a command-line tool, a terminal UI built on Textual, and an MCP server that gives AI agents the same operations. Together they fetch our domain inventory from registrars, price it, and build this website.
All three started slowly. --help on the CLI took about 1.3 seconds on a busy
development Mac, because the CLI's main module imported nearly everything at
module scope: the site generator, the registrar clients and the database
layer. Python 3.15 added explicit lazy imports
(PEP 810), so we used them. We also
adopted the new built-in frozendict
(PEP 814) and documented the new sampling
profiler (PEP 799).
The work took six pull requests. This post covers what we measured, the safety gate we built before deferring anything, the places where a lazy import is not lazy, and the results, including the two entry points that improved much less than the CLI.
Where the start-up time went
python -X importtime prints how long each import takes, including the
imports it triggers. On the CLI, the expensive chains all served work that
--help never does:
Package loaded by --help |
Cumulative ms | First reached through |
|---|---|---|
| markdown-it | 694 | CLI → site generator → agent documentation |
| httpx2 | 331 | CLI → DNS checker |
| asyncio | 321 | CLI → configuration → credentials |
| DuckDB | 241 | CLI → coupons → domain model → types |
| rcssmin | 192 | CLI → site generator |
| Jinja2 | 191 | CLI → site generator |
| PyYAML | 146 | CLI → coupons → domain model → sale policy |
These figures come from one run at a load average of about 50. They are cumulative, so nested packages overlap.
To bound what deferral could save, we ran each entry point with
PYTHON_LAZY_IMPORTS=all, which makes every import lazy. The output stayed
byte-identical. The CLI's --help fell from 2,230 ms to 474 ms, the TUI's
from 3,615 ms to 209 ms, and importing the MCP server from 5,667 ms to
1,682 ms. That run was at a load average of 140 to 240, so only the ratios
mean anything.
Why we did not turn on global lazy mode
Global mode is one switch, but we kept it out of production. Lifeguard, the
analyzer described below, found import-time effects that deferral could change
in 160 of the 801 files in our package and its dependencies. One of them is
our own configuration module, which fills os.environ from a .env file when
it is imported. Everything that reads the environment afterwards depends on
that import running first.
Explicit statements apply only where they are written, and reviewers see them:
from app import config # fills os.environ at import, so it stays eager
lazy from app import website
lazy from app.clients import build_registrar
lazy import app.valuation
Module names in the examples are simplified.
A gate decides which imports may be deferred
A deferred import moves the target module's import-time work from start-up to first use. That is safe only if the work does not depend on anything that changes in between. We did not want to judge that by eye for every statement, so the first pull request added a gate and no lazy statements.
The gate runs Lifeguard, Meta's static
analyzer for lazy-import safety, pinned at version 0.7.0. Lifeguard reports
which modules have import-time effects, which must load their imports eagerly,
and which form import cycles. The gate then finds every lazy statement with
ast and checks five rules:
- The importing module must not be one that Lifeguard says must load
eagerly, such as a module that writes to
sys.modules. - The target must have no import-time effects that deferral could change, unless an allowlist entry vouches for that exact pair.
- The target must not be one of the importer's own eager dependencies: an unsafe module it uses, a submodule it reaches only by attribute, or a member of an import cycle it enters.
- An importer that has effects of its own must not defer a module in its own import cycle.
- The configuration module is never deferred. No allowlist entry can override this rule.
The gate fails closed. It fails if it scans no files, if Lifeguard exits with an error, if Lifeguard's report names none of our modules, or if an allowlist entry matches no lazy statement. A commit hook runs it whenever a Python file in the package, the allowlist or the lock file changes, because any edit can give a module a new import-time effect. One Lifeguard run over our package and its dependencies took 8.4 seconds.
Rule 1 needed a code change first. The CLI's main module loaded a deployment
script by path and registered it in sys.modules, so Lifeguard required the
whole module to load eagerly. Moving that loader into a small helper module
made the CLI eligible.
The allowlist records each target's effects
The heaviest modules on our chains were unsafe, because each reads packaged templates or word lists when imported. Deferring one takes an allowlist entry with a reason and the target's current effects:
[[allow]]
importer = "app.cli.main"
target = "app.direct_sale"
reason = "At import it only builds an importlib.resources path to its packaged agreement templates, which does not depend on the working directory, the environment or sys.path. The templates are read later, inside functions."
effects = ["UnsafeMethodCall importlib.resources.abc.Traversable.joinpath"]
The reason has to explain why the effects can run later. In particular, they
must not depend on state that a command changes after start-up, such as the
working directory, environment variables or sys.path. A deferred module's
import errors also move from start-up to first use, and the reason has to
accept that.
effects lists Lifeguard's findings for the target, without line numbers. If
the list changes, the gate fails and prints the new one. That check caught a
real change. Another pull request, merged while the CLI change was in review,
made the site generator build a set from another module's table at import. On
the rebase, Lifeguard reported two new effects for the site generator and the
gate refused the old entry. We confirmed that the reason still held and
recorded the new effects.
Lifeguard does not see everything. A probe module that only called print was
reported as eligible. The gate enforces the analyzer's model, and a reviewer
still reads every new lazy statement. Today the gate checks 59 lazy statements
in four files, with 8 allowlist entries.
Tests run our lazy statements eagerly
A lazy from m import f binds f when the importing module first uses it,
not when the module loads. If a test patches m.f before that first use, the
importer keeps the mock after the patch ends:
# user.py
lazy from src import f
def call():
return f()
from unittest import mock
import user # src has not been imported yet
with mock.patch("src.f", lambda: "mock"):
print(user.call()) # mock: the first use binds f to the patch
print(user.call()) # still mock after the patch is undone
That is what Python 3.15.2 printed for us. A lazy import m avoids the
problem, because each call looks up m.f. Switching to that form would have
renamed every call site, though, and our CLI tests patch names on the CLI
package itself, a contract we keep on purpose.
So the test process makes our own lazy statements eager. In 3.15.2,
PYTHON_LAZY_IMPORTS and -X lazy_imports accept only all and normal,
and neither makes an explicit lazy statement eager.
sys.set_lazy_imports_filter can: a filter that returns False loads the
import at once.
# conftest.py, before the package is imported
import sys
def eager_in_package(importer: str, name: str, fromlist: object = None) -> bool:
return not (importer == "app" or importer.startswith("app."))
sys.set_lazy_imports_filter(eager_in_package)
In-process tests then see the old import timing and patch behavior. The lazy
path gets its own checks. Subprocess tests start the real entry points
without the filter and assert which packages --help must not load, and the
gate checks every statement statically.
We also ran the whole suite once without the filter, through a one-off pytest plugin. Both runs passed 14,342 tests. Every failure in either run also failed on the base commit, passed when rerun alone, or was the filter's own self-test, which must fail without the filter. No test failed from patch capture.
Places where a lazy import is not lazy
A lazy binding resolves the first time code looks up the name. Some lookups happen while the module itself loads, which quietly undoes the deferral:
- Module-level tables. The CLI kept dictionaries that mapped registrar and appraisal-source names to factory functions. Building a dictionary looked up every factory, which imported the clients package at start-up.
- Default argument values. Three MCP tools take a default value from the clients package. Python evaluates defaults when it creates the function, so registering those tools imports the clients package anyway, and with it DuckDB, httpx2 and PyYAML.
- Base classes. The TUI's application class subclasses Textual's
App, so Textual has to load before the class body runs. tryblocks.lazyinsidetryis a syntax error ("lazy import not allowed inside try/except blocks"), as it is inside a function. Both Textual and FastMCP were imported insidetryblocks.
The tables were the easy case. Each value now looks the factory up when it is called:
lazy from app.clients import build_escrow, build_mailer
# Looks up both names, importing app.clients, when this module loads:
CLIENT_BUILDERS = {"escrow": build_escrow, "mailer": build_mailer}
# Looks up each name only when a command builds that client:
CLIENT_BUILDERS = {"escrow": lambda: build_escrow(), "mailer": lambda: build_mailer()}
The TUI work added one more rule: defer only what would not load anyway. The TUI's constants module builds a color table from the registrar list at import, which loads the clients package, the database layer and DuckDB. Deferring those modules in the TUI's main module would only move a binding and add allowlist entries, so they stay eager.
Results
Each figure is the median of nine runs. We made ten runs per side, alternating
between the old and new code in one session, and discarded the first. The
machine was an Apple silicon development Mac running other work, so each row
lists the load average during its runs. Both --help outputs were
byte-identical before and after.
| Process | Modules loaded | Median time | Load average |
|---|---|---|---|
CLI --help |
664 → 308 | 1,314 ms → 326 ms | 70–85 |
TUI --help |
797 → 620 | 1,768 ms → 1,406 ms | 21–30 |
| Importing the MCP server | 1,224 → 1,055 | 3,341 ms → 2,827 ms | 47–80 |
The CLI's --help became four times faster. It no longer loads DuckDB,
Jinja2, markdown-it, PyYAML, httpx2 or rcssmin, and a subprocess test pins
that list, so an import that brings one back fails the suite.
The TUI improved by 20 percent and the MCP server by 15 percent; the MCP
server registered the same 94 tools both ways. Most of what remains is
Textual or FastMCP themselves, plus the clients chain described above.
Removing it would mean restructuring the entry points and the clients
package, not adding more lazy keywords.
We left two smaller costs in place. asyncio still loads at --help, because
the configuration module imports the credentials module, which imports
asyncio; changing that means changing how the credentials module avoids an
import cycle. rich.progress costs about 71 ms, but an allowlist entry for a
third-party module would fail the gate whenever that library's import-time
effects change.
We added no run-time switch to turn the lazy statements off. Rollback is a revert.
frozendict
Python 3.15 also added a built-in immutable mapping, frozendict. It is
hashable, it is a Mapping, and it works with json, pickle and copy. It
is not a subclass of dict.
We replaced our three MappingProxyType constants with frozendict and made
it the rule for new constant mappings:
from collections.abc import Mapping
STATUS_LABELS: Mapping[str, str] = frozendict({ # pyrefly: ignore[bad-argument-count]
"active": "For sale",
"sold": "Sold",
})
We did not convert the 121 module-level dict constants we already had. The
code has about 530 isinstance(x, dict) checks, and a frozendict fails every
one of them.
Two tools needed attention. Under a Python 3.15 target, pyrefly 1.4.0 and
1.4.1 type frozendict.__init__ with no parameters, so every constructor
call with an argument is an error. The 1.5.0.dev1 pre-release accepts them. We
stayed on the stable release, added the targeted ignore shown above, and will
remove those ignores with pyrefly suppress --remove-unused-ignores after we
upgrade. Ruff and ty accepted the code as written.
Our change-based test selection fingerprints module-level values to spot state
that tests share. It read a frozendict as unknown, so we taught it to read
the members through the built-in type's own items. Code review then pointed
out that a frozendict subclass can carry mutable attributes, so only the
exact type takes that path.
The sampling profiler
Python 3.15 ships a sampling profiler as python -m profiling.sampling. It
reads a process's stacks at intervals without instrumenting the code. It can
attach to a running process, dump its current stacks, replay a saved
capture into another format, or run a program from its first line.
On macOS, reading another process's memory requires elevated permissions;
without them, the profiler stops with "Unable to access process memory on
macOS". The first draft of
our how-to ran the CLI under sudo with run. Review caught that run
starts its target without dropping privileges, so the database, output files
and __pycache__ would have become root-owned. The documented pattern starts
the program as yourself and gives sudo to the profiler alone:
uv run my-tui
# In a second terminal:
sudo -E .venv/bin/python -m profiling.sampling attach --flamegraph -o profile.html "$(pgrep -n -f my-tui)"
sudo chown "$USER" profile.html
Use the project's interpreter, because System Integrity Protection may block
the system Python. We have not used the profiler on this code yet. For
start-up, -X importtime answers the question more directly: what loads, and
through which chain.
What we left out
Some 3.15 additions did not earn a change:
- Sentinel values (PEP 661) and unpacking in comprehensions (PEP 798): rewriting two sentinels and 38 flattening comprehensions would change nothing that a reader or a user notices.
- Closed
TypedDict(PEP 728): a typing change that is best made when each of our 15 classes next changes. - TOML 1.1: other tools read the same files and do not support it.
- base64's new padding options: checkout links that already sit in buyers' inboxes must keep decoding exactly as before.
Try it on your own code
Start with the measurement. Sort this output by its cumulative column and read the chains behind the largest entries:
python -X importtime -m yourapp --help 2> importtime.txt
Then ask Lifeguard which modules are safe to defer. Its JSON report lists the
modules that may be deferred (LAZY_ELIGIBLE) and those that must load their
imports eagerly (LOAD_IMPORTS_EAGERLY):
uvx --from lifeguard-lazy-imports==0.7.0 lifeguard run-tree src/yourapp report.json --python-version 3.15
Pass --site-packages so that it can follow your dependencies. Lifeguard did
not follow a symlinked package for us, so our gate copies the package's
source into a temporary tree first.
Before you add the first lazy from, search your tests for patches of the
names you plan to defer. A filter like the one above keeps those tests honest,
and subprocess tests keep the lazy path honest.
Comments
Comments are reviewed before publication. The Author badge marks replies from the author.
Your test session has expired. Stop testing to use comments and sign-in normally, or open a fresh test link to keep testing.
Stopping testing reloads this page in normal mode.
Read approved comments on the discussion page.
Open the discussion page