Namegarage

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

Dev blog#python#performance#developer-tools

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:

Largest import chains behind the CLI's --help before the change
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:

  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:

[[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.
  • 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:

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.

Start-up before and after explicit lazy imports
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.

Add as preferred source
Hacker News X LinkedIn Email

Using this button loads Google’s service and allows its usage measurement. Privacy policy.

Get new blog posts by email

Comments

Comments are reviewed before publication. The Author badge marks replies from the author.

Read approved comments on the discussion page.

    Open the discussion page

    Leave a comment

    Your account name, sign-in provider, and comment become public after review. Your email stays private. See the blog comments privacy notice and Privacy Policy.

    Sign in to leave a comment.