{"slug": "adopting-python-3-15-s-lazy-imports-a-4x-faster-cli-and-where-they-didn-t-help", "title": "Adopting Python 3.15's lazy imports: a 4× faster CLI, and where they didn't help", "summary": "A back-office Python package moved to Python 3.15.2 on November 3 and adopted PEP 810 explicit lazy imports, cutting CLI `--help` start-up from 2,230 ms to 474 ms under a global `PYTHON_LAZY_IMPORTS=all` test run, while the terminal UI fell from 3,615 ms to 209 ms and MCP server import from 5,667 ms to 1,682 ms. The team kept global lazy mode out of production after Meta's Lifeguard static analyzer, pinned at version 0.7.0, flagged import-time effects in 160 of the 801 files in the package and its dependencies, including a configuration module that fills `os.environ` from a `.env` file at import. The change shipped across six pull requests and also adopted the built-in `frozendict` (PEP 814) and documented the new sampling profiler (PEP 799).", "body_md": "# Adopting Python 3.15's lazy imports: a 4× faster CLI, and where they didn't help\n\nOn 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.\n\nAll three started slowly. `--help` on the CLI took about 1.3 seconds on a busy\ndevelopment Mac, because the CLI's main module imported nearly everything at\nmodule scope: the site generator, the registrar clients and the database\nlayer. Python 3.15 added explicit lazy imports\n([PEP 810](https://peps.python.org/pep-0810/)), so we used them. We also\nadopted the new built-in `frozendict`\n([PEP 814](https://peps.python.org/pep-0814/)) and documented the new sampling\nprofiler ([PEP 799](https://peps.python.org/pep-0799/)).\n\nThe 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.\n\n## Where the start-up time went\n\n`python -X importtime` prints how long each import takes, including the\nimports it triggers. On the CLI, the expensive chains all served work that\n`--help` never does:\n\n| Largest import chains behind the CLI's --help before the change |  |  | \n|---|---|---|\n| Package loaded by `--help` | Cumulative ms | First reached through | \n|---|---|---|\n| markdown-it | 694 | CLI → site generator → agent documentation | \n| httpx2 | 331 | CLI → DNS checker | \n| asyncio | 321 | CLI → configuration → credentials | \n| DuckDB | 241 | CLI → coupons → domain model → types | \n| rcssmin | 192 | CLI → site generator | \n| Jinja2 | 191 | CLI → site generator | \n| PyYAML | 146 | CLI → coupons → domain model → sale policy | \n\nThese figures come from one run at a load average of about 50. They are cumulative, so nested packages overlap.\n\nTo bound what deferral could save, we ran each entry point with\n`PYTHON_LAZY_IMPORTS=all`, which makes every import lazy. The output stayed\nbyte-identical. The CLI's `--help` fell from 2,230 ms to 474 ms, the TUI's\nfrom 3,615 ms to 209 ms, and importing the MCP server from 5,667 ms to\n1,682 ms. That run was at a load average of 140 to 240, so only the ratios\nmean anything.\n\n## Why we did not turn on global lazy mode\n\nGlobal mode is one switch, but we kept it out of production. Lifeguard, the\nanalyzer described below, found import-time effects that deferral could change\nin 160 of the 801 files in our package and its dependencies. One of them is\nour own configuration module, which fills `os.environ` from a `.env` file when\nit is imported. Everything that reads the environment afterwards depends on\nthat import running first.\n\nExplicit statements apply only where they are written, and reviewers see them:\n\n``` python\nfrom app import config  # fills os.environ at import, so it stays eager\n\nlazy from app import website\nlazy from app.clients import build_registrar\nlazy import app.valuation\n```\n\nModule names in the examples are simplified.\n\n## A gate decides which imports may be deferred\n\nA 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.\n\nThe gate runs [Lifeguard](https://github.com/facebook/Lifeguard), Meta's static\nanalyzer for lazy-import safety, pinned at version 0.7.0. Lifeguard reports\nwhich modules have import-time effects, which must load their imports eagerly,\nand which form import cycles. The gate then finds every `lazy` statement with\n`ast` and checks five rules:\n\n1. The importing module must not be one that Lifeguard says must load\neagerly, such as a module that writes to `sys.modules` .\n2. The target must have no import-time effects that deferral could change, unless an allowlist entry vouches for that exact pair.\n3. 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.\n4. An importer that has effects of its own must not defer a module in its own import cycle.\n5. The configuration module is never deferred. No allowlist entry can override this rule.\n\nThe 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.\n\nRule 1 needed a code change first. The CLI's main module loaded a deployment\nscript by path and registered it in `sys.modules`, so Lifeguard required the\nwhole module to load eagerly. Moving that loader into a small helper module\nmade the CLI eligible.\n\n### The allowlist records each target's effects\n\nThe 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:\n\n```\n[[allow]]\nimporter = \"app.cli.main\"\ntarget = \"app.direct_sale\"\nreason = \"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.\"\neffects = [\"UnsafeMethodCall importlib.resources.abc.Traversable.joinpath\"]\n```\n\nThe reason has to explain why the effects can run later. In particular, they\nmust not depend on state that a command changes after start-up, such as the\nworking directory, environment variables or `sys.path`. A deferred module's\nimport errors also move from start-up to first use, and the reason has to\naccept that.\n\n`effects` lists Lifeguard's findings for the target, without line numbers. If\nthe list changes, the gate fails and prints the new one. That check caught a\nreal change. Another pull request, merged while the CLI change was in review,\nmade the site generator build a set from another module's table at import. On\nthe rebase, Lifeguard reported two new effects for the site generator and the\ngate refused the old entry. We confirmed that the reason still held and\nrecorded the new effects.\n\nLifeguard does not see everything. A probe module that only called `print` was\nreported as eligible. The gate enforces the analyzer's model, and a reviewer\nstill reads every new lazy statement. Today the gate checks 59 lazy statements\nin four files, with 8 allowlist entries.\n\n## Tests run our lazy statements eagerly\n\nA lazy `from m import f` binds `f` when the importing module first uses it,\nnot when the module loads. If a test patches `m.f` before that first use, the\nimporter keeps the mock after the patch ends:\n\n``` python\n# user.py\nlazy from src import f\n\ndef call():\n    return f()\npython\nfrom unittest import mock\n\nimport user  # src has not been imported yet\n\nwith mock.patch(\"src.f\", lambda: \"mock\"):\n    print(user.call())  # mock: the first use binds f to the patch\nprint(user.call())      # still mock after the patch is undone\n```\n\nThat is what Python 3.15.2 printed for us. A lazy `import m` avoids the\nproblem, because each call looks up `m.f`. Switching to that form would have\nrenamed every call site, though, and our CLI tests patch names on the CLI\npackage itself, a contract we keep on purpose.\n\nSo the test process makes our own lazy statements eager. In 3.15.2,\n`PYTHON_LAZY_IMPORTS` and `-X lazy_imports` accept only `all` and `normal`,\nand neither makes an explicit `lazy` statement eager.\n`sys.set_lazy_imports_filter` can: a filter that returns `False` loads the\nimport at once.\n\n``` python\n# conftest.py, before the package is imported\nimport sys\n\ndef eager_in_package(importer: str, name: str, fromlist: object = None) -> bool:\n    return not (importer == \"app\" or importer.startswith(\"app.\"))\n\nsys.set_lazy_imports_filter(eager_in_package)\n```\n\nIn-process tests then see the old import timing and patch behavior. The lazy\npath gets its own checks. Subprocess tests start the real entry points\nwithout the filter and assert which packages `--help` must not load, and the\ngate checks every statement statically.\n\nWe 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.\n\n## Places where a lazy import is not lazy\n\nA lazy binding resolves the first time code looks up the name. Some lookups happen while the module itself loads, which quietly undoes the deferral:\n\n- **Module-level tables.** The CLI kept dictionaries that mapped registrar\nand appraisal-source names to factory functions. Building a dictionary\nlooked up every factory, which imported the clients package at start-up.\n- **Default argument values.** Three MCP tools take a default value from the\nclients package. Python evaluates defaults when it creates the function, so\nregistering those tools imports the clients package anyway, and with it\nDuckDB, httpx2 and PyYAML.\n- **Base classes.** The TUI's application class subclasses Textual's`App` ,\nso Textual has to load before the class body runs.\n- **`try` blocks.**`lazy` inside`try` is a syntax error (\"lazy import not\nallowed inside try/except blocks\"), as it is inside a function. Both Textual\nand FastMCP were imported inside`try` blocks.\n\nThe tables were the easy case. Each value now looks the factory up when it is called:\n\n``` python\nlazy from app.clients import build_escrow, build_mailer\n\n# Looks up both names, importing app.clients, when this module loads:\nCLIENT_BUILDERS = {\"escrow\": build_escrow, \"mailer\": build_mailer}\n\n# Looks up each name only when a command builds that client:\nCLIENT_BUILDERS = {\"escrow\": lambda: build_escrow(), \"mailer\": lambda: build_mailer()}\n```\n\nThe 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.\n\n## Results\n\nEach figure is the median of nine runs. We made ten runs per side, alternating\nbetween the old and new code in one session, and discarded the first. The\nmachine was an Apple silicon development Mac running other work, so each row\nlists the load average during its runs. Both `--help` outputs were\nbyte-identical before and after.\n\n| Start-up before and after explicit lazy imports |  |  |  | \n|---|---|---|---|\n| Process | Modules loaded | Median time | Load average | \n|---|---|---|---|\n| CLI `--help` | 664 → 308 | 1,314 ms → 326 ms | 70–85 | \n| TUI `--help` | 797 → 620 | 1,768 ms → 1,406 ms | 21–30 | \n| Importing the MCP server | 1,224 → 1,055 | 3,341 ms → 2,827 ms | 47–80 | \n\nThe CLI's `--help` became four times faster. It no longer loads DuckDB,\nJinja2, markdown-it, PyYAML, httpx2 or rcssmin, and a subprocess test pins\nthat list, so an import that brings one back fails the suite.\n\nThe TUI improved by 20 percent and the MCP server by 15 percent; the MCP\nserver registered the same 94 tools both ways. Most of what remains is\nTextual or FastMCP themselves, plus the clients chain described above.\nRemoving it would mean restructuring the entry points and the clients\npackage, not adding more `lazy` keywords.\n\nWe left two smaller costs in place. `asyncio` still loads at `--help`, because\nthe configuration module imports the credentials module, which imports\n`asyncio`; changing that means changing how the credentials module avoids an\nimport cycle. `rich.progress` costs about 71 ms, but an allowlist entry for a\nthird-party module would fail the gate whenever that library's import-time\neffects change.\n\nWe added no run-time switch to turn the lazy statements off. Rollback is a revert.\n\n## frozendict\n\nPython 3.15 also added a built-in immutable mapping, `frozendict`. It is\nhashable, it is a `Mapping`, and it works with `json`, `pickle` and `copy`. It\nis not a subclass of `dict`.\n\nWe replaced our three `MappingProxyType` constants with `frozendict` and made\nit the rule for new constant mappings:\n\n``` python\nfrom collections.abc import Mapping\n\nSTATUS_LABELS: Mapping[str, str] = frozendict({  # pyrefly: ignore[bad-argument-count]\n    \"active\": \"For sale\",\n    \"sold\": \"Sold\",\n})\n```\n\nWe did not convert the 121 module-level `dict` constants we already had. The\ncode has about 530 `isinstance(x, dict)` checks, and a `frozendict` fails every\none of them.\n\nTwo tools needed attention. Under a Python 3.15 target, pyrefly 1.4.0 and\n1.4.1 type `frozendict.__init__` with no parameters, so every constructor\ncall with an argument is an error. The 1.5.0.dev1 pre-release accepts them. We\nstayed on the stable release, added the targeted ignore shown above, and will\nremove those ignores with `pyrefly suppress --remove-unused-ignores` after we\nupgrade. Ruff and ty accepted the code as written.\n\nOur change-based test selection fingerprints module-level values to spot state\nthat tests share. It read a `frozendict` as unknown, so we taught it to read\nthe members through the built-in type's own `items`. Code review then pointed\nout that a `frozendict` subclass can carry mutable attributes, so only the\nexact type takes that path.\n\n## The sampling profiler\n\nPython 3.15 ships a sampling profiler as `python -m profiling.sampling`. It\nreads a process's stacks at intervals without instrumenting the code. It can\n`attach` to a running process, `dump` its current stacks, `replay` a saved\ncapture into another format, or `run` a program from its first line.\n\nOn macOS, reading another process's memory requires elevated permissions;\nwithout them, the profiler stops with \"Unable to access process memory on\nmacOS\". The first draft of\nour how-to ran the CLI under `sudo` with `run`. Review caught that `run`\nstarts its target without dropping privileges, so the database, output files\nand `__pycache__` would have become root-owned. The documented pattern starts\nthe program as yourself and gives `sudo` to the profiler alone:\n\n```\nuv run my-tui\n# In a second terminal:\nsudo -E .venv/bin/python -m profiling.sampling attach --flamegraph -o profile.html \"$(pgrep -n -f my-tui)\"\nsudo chown \"$USER\" profile.html\n```\n\nUse the project's interpreter, because System Integrity Protection may block\nthe system Python. We have not used the profiler on this code yet. For\nstart-up, `-X importtime` answers the question more directly: what loads, and\nthrough which chain.\n\n## What we left out\n\nSome 3.15 additions did not earn a change:\n\n- **Sentinel values (PEP 661) and unpacking in comprehensions (PEP 798):** rewriting two sentinels and 38 flattening comprehensions would change\nnothing that a reader or a user notices.\n- **Closed `TypedDict` (PEP 728):** a typing change that is best made when\neach of our 15 classes next changes.\n- **TOML 1.1:** other tools read the same files and do not support it.\n- **base64's new padding options:** checkout links that already sit in\nbuyers' inboxes must keep decoding exactly as before.\n\n## Try it on your own code\n\nStart with the measurement. Sort this output by its cumulative column and read the chains behind the largest entries:\n\n```\npython -X importtime -m yourapp --help 2> importtime.txt\n```\n\nThen ask Lifeguard which modules are safe to defer. Its JSON report lists the\nmodules that may be deferred (`LAZY_ELIGIBLE`) and those that must load their\nimports eagerly (`LOAD_IMPORTS_EAGERLY`):\n\n```\nuvx --from lifeguard-lazy-imports==0.7.0 lifeguard run-tree src/yourapp report.json --python-version 3.15\n```\n\nPass `--site-packages` so that it can follow your dependencies. Lifeguard did\nnot follow a symlinked package for us, so our gate copies the package's\nsource into a temporary tree first.\n\nBefore you add the first `lazy from`, search your tests for patches of the\nnames you plan to defer. A filter like the one above keeps those tests honest,\nand subprocess tests keep the lazy path honest.", "url": "https://wpnews.pro/news/adopting-python-3-15-s-lazy-imports-a-4x-faster-cli-and-where-they-didn-t-help", "canonical_source": "https://namegarage.com/blog/python-3-15-lazy-imports", "published_at": "2026-11-14 15:41:51+00:00", "updated_at": "2026-11-14 15:52:48.492310+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-tools"], "entities": ["Python 3.15.2", "PEP 810", "PEP 814", "PEP 799", "Lifeguard", "Meta", "Textual", "DuckDB"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/adopting-python-3-15-s-lazy-imports-a-4x-faster-cli-and-where-they-didn-t-help", "markdown": "https://wpnews.pro/news/adopting-python-3-15-s-lazy-imports-a-4x-faster-cli-and-where-they-didn-t-help.md", "text": "https://wpnews.pro/news/adopting-python-3-15-s-lazy-imports-a-4x-faster-cli-and-where-they-didn-t-help.txt", "jsonld": "https://wpnews.pro/news/adopting-python-3-15-s-lazy-imports-a-4x-faster-cli-and-where-they-didn-t-help.jsonld"}}