---
title: 'Adopting Python 3.15''s lazy imports: a 4× faster CLI, and where they didn''t
  help'
url: https://namegarage.com/blog/python-3-15-lazy-imports
author: Namegarage
date: '2026-11-08'
description: We deferred imports in a CLI, a terminal UI and an MCP server with PEP
  810, behind a static safety gate. One start-up became four times faster; the other
  two improved by 15 to 20 percent.
ai-disclosure: ai-assisted
---

# Adopting Python 3.15's lazy imports: a 4× faster CLI, and where they didn't help

This article was prepared with AI assistance and reviewed by a person before publication.

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](https://peps.python.org/pep-0810/)), so we used them. We also
adopted the new built-in `frozendict`
([PEP 814](https://peps.python.org/pep-0814/)) and documented the new sampling
profiler ([PEP 799](https://peps.python.org/pep-0799/)).

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:

```python
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](https://github.com/facebook/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:

1. The importing module must not be one that Lifeguard says must load
   eagerly, such as a module that writes to `sys.modules`.
2. The target must have no import-time effects that deferral could change,
   unless an allowlist entry vouches for that exact pair.
3. 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.
4. An importer that has effects of its own must not defer a module in its own
   import cycle.
5. 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:

```toml
[[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:

```python
# user.py
lazy from src import f

def call():
    return f()
```

```python
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.

```python
# 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.
- **`try` blocks.** `lazy` inside `try` is a syntax error ("lazy import not
  allowed inside try/except blocks"), as it is inside a function. Both Textual
  and FastMCP were imported inside `try` blocks.

The tables were the easy case. Each value now looks the factory up when it is
called:

```python
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:

```python
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:

```bash
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:

```bash
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`):

```bash
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.
