3.15 Lazy Imports Cut a CLI in Half. Your FastAPI App Is a Different Animal

Real Python's September 9 tutorial measures 68ms to 32ms on --help with five lazy keywords. PEP 810 is explicit. Plugin registration at import time still has to stay eager.

Startup time is a CLI problem that web people pretend they do not have, until the container is 800 milliseconds of imports and the health check is already bored.

Stephen Gruppetta’s Real Python tutorial, updated September 9, walks Python 3.15’s new lazy import through a small command-line tool. Five keywords. --help goes from 68 milliseconds to 32 on their machine. Modules loaded drop from 222 to 76. The final release is October 1. The build they used is 3.15.0rc1.

Yesterday we wrote about 3.15rc1 freezing the ABI and Ruff rewriting except. That was wheels and formatters. This is why your FastAPI image takes a coffee to import app.

What the keyword actually does

lazy import json is a SyntaxError on 3.14. On 3.15 it succeeds and prints nothing, which is the point: the name is bound, the module is not loaded. Real Python’s probe module prints when it loads. After the lazy line, it is still missing from sys.modules. Reading noisy_module.VALUE is what pulls it in. PEP 810 calls that moment reification.

Two forms work: lazy import xml.etree.ElementTree as ET and lazy from http.server import HTTPServer. Dotted imports resolve in stages. Reading xml loads the top package; the submodule waits until you touch it.

from shapes import CIRCLE, SQUARE is sneakier. Touching CIRCLE has to run the whole module, so SQUARE still sits as a lazy_import placeholder until something reads it. The module already paid the cost.

Places Python will not accept lazy: function bodies, class bodies, try/except/else/finally, lazy from module import *, lazy from __future__. Module-level if, for, while, with, and match are fine. Each rejection has its own SyntaxError text. lazy is a soft keyword, so a variable named lazy still works.

That try ban is not pedantry. If the import is deferred, except ImportError around the import statement never sees the failure. The failure happens later, on first use, somewhere you did not write a handler. Putting lazy import inside try would look like it worked.

Why PEP 690 died and PEP 810 is this

In 2022, PEP 690 wanted implicit, global lazy imports behind a switch. The Steering Council rejected it. Real Python’s summary: it would have split the community into two Pythons and forced library authors to test both ways.

PEP 810 is explicit. The keyword applies to one import. It does not cascade into the modules you import. There is still a process-wide opt-in if you own the app: -X lazy_imports=all, PYTHON_LAZY_IMPORTS, or sys.set_lazy_imports(). When several are set, the function call wins, then the flag, then the environment variable. A none mode is in the PEP as history and is not shipped; asking for it raises ValueError.

__lazy_modules__ is the compatibility hatch. Older Pythons ignore the name. 3.15 can defer those modules without a SyntaxError in the file. Libraries that still have to run on 3.14 will use that, not the keyword.

The old workarounds are still in the tutorial’s table. Import inside a function: defers, hides the dependency. Module-level __getattr__: defers, adds machinery. if TYPE_CHECKING: only for type hints. importlib.util.LazyLoader: whole modules, nobody uses it. PEP 810 counted about 17 percent of the standard library’s non-test imports already sitting inside functions. The keyword is how you get that deferral without moving the line.

The CLI measurement, and why --help is a cheat that still matters

Their sample tool, report, imports argparse, csv, asyncio, http.server, statistics, tkinter, xml.etree.ElementTree, and a couple of handler plugins. --help needs almost none of that.

Warm python -X importtime on the eager version, top cumulative microseconds: asyncio about 27 milliseconds, http.server about 12, then site, _colorize, tkinter, argparse. 222 import-time lines to print a paragraph of help. Cold runs are worse because tkinter pulls Tk off disk.

They left argparse and csv eager. argparse runs every time. csv is under a millisecond. statistics went lazy even though summarizing is the tool’s job, because --help never summarizes. The handler plugins stayed eager for a reason the traps section cares about: import-time registration.

Five lazy prefixes. Best of ten: 68 ms to 32 ms. Import lines: 222 to 76. Two-thirds of the modules disappeared; only about half the time went with them, because the remaining imports include the expensive ones you still need.

--help is the favorable case. A code path that touches every deferred module pays the cost later, not never. That is still what you want for a CLI that people run fifty times a day, and for a container that starts, serves one request, and dies.

What this does not do for FastAPI

A long-running uvicorn worker pays import cost once. If your process lives for hours, 36 milliseconds off --help is not your incident. Your incident is:

  • cold start on Cloud Run / Lambda / a scale-to-zero FastAPI
  • import app pulling pandas, torch, or a client SDK you only need on one route
  • test collection importing the whole stack to discover one function
  • Starlette sitting under FastAPI and everything else you dragged in last week for a CVE

Lazy imports help the cold-start class. They do not help a worker that already imported the world at boot, unless you stop importing the world at boot.

The trap Real Python leans on is import-time side effects. A plugin that registers itself when the module loads will never register if nothing reads the name. That is exactly how a lot of FastAPI and Django code still works: import routes as a way to attach handlers. Mark that lazy and your app starts fast and serves 404s. Leave it eager.

Same for ORMs that configure on import, metrics that open a socket on import, and any module whose docstring should have said “do not import unless you mean it.”

Looking at the name reifies it. print(noisy_module), type(noisy_module), a debugger hover: all load the module. To inspect without loading, globals()["noisy_module"] and do not bind it. The repr is <lazy_import 'noisy_module'>. Attribute access on that placeholder errors; it does not sneak-load.

A web-shaped adoption order

If you ship a CLI next to a web app, do the CLI first. Copy Real Python: measure python -X importtime on --help and on the common path. Lazy the unused heavy modules. Do not lazy the plugin package.

If you ship FastAPI, measure import app in a throwaway 3.15rc1 venv, not in production. Candidates: lazy import the optional exporters, the admin UI, the ML extra, the XML dump nobody hit this quarter. Keep eager: the app object, the router modules that register on import, settings, logging.

Django is ruder. Apps fill AppConfig.ready() and model modules have side effects. __lazy_modules__ might help a management command. It will not let you lazy django.contrib and keep migrate. Treat the tutorial’s “adopt in an application” section as for code you own, not for the framework.

Do not sprinkle lazy in a library you publish unless you have a 3.15-only extra or you use __lazy_modules__. A SyntaxError on 3.14 is not a warning. It is a broken wheel.

If you are still on 3.12 in prod, this is reading material until October, then until your base image moves. Pair it with the HTMX piece only in the sense that both are about shipping less JavaScript and less Python until someone asks. Different layers. Same instinct.

What to type this week

Install 3.15.0rc1 with uv, not an early alpha from before the keyword. Real Python’s check: uv run --python 3.15 python -c "lazy import json" must not SyntaxError.

Point -X importtime at the entry that hurts. Sort by cumulative. Lazy the ones that do not run on the path you care about. Re-bench. If a plugin disappears from the app, you found a side effect; put that import back.

Do not turn on -X lazy_imports=all in production because a blog told you to. That is PEP 690’s ghost. All-mode is for an application owner who measured it. Libraries should stay explicit.

Type checkers will have opinions. Real Python spends a section retiring if TYPE_CHECKING for imports that existed only to please annotations. The lazy keyword is supposed to let the import sit at the top and still not run. If your pyright config assumes every top-level import is a runtime dependency, you will get noisy reports the first week. That is a config problem, not a reason to keep hiding imports in functions.

The report tool’s handler packages are the web analogue you should steal. They stayed eager because loading them is how the CLI finds output formats. Your FastAPI equivalent is import foo.routes at the bottom of main.py. If you lazy that, you optimize a process that cannot route. If you lazy foo.exporters.xml that only a cron hits, you win.

Gunicorn with four workers is four import bills at deploy. Lazy imports still help there, once each, which is why people shrug. Prefork, then a worker crash, then a respawn: you pay again. Scale-to-zero runtimes pay every time. That is the deployment shape this feature was built for, even if the tutorial’s demo is a CSV tool with tkinter hanging off --help for no good reason except pedagogy.

Do not mix this with “just use PyPy” or “just use uvloop” as a single tweet. Those are runtime choices. Lazy imports are a loading choice. You can do all three and still have a slow query.

If your pain is test collection, pytest importing the app package is the same shape as --help. Lazy the extras the tests do not need. Do not lazy the fixtures that create the client. You will get mysterious empty apps and a day of blaming pytest-asyncio.

ASGI lifespan hooks are another eager island. Anything you put in startup already runs. Lazy imports will not save a lifespan that imports torch to be polite. Move that import into the request path that needs it, or accept the bill. Do not hide torch behind lazy and then import it in every request anyway.

October 1, the rc becomes the final. The ABI freeze from yesterday still matters if you ship C extensions. Lazy imports matter if you ship a command people run, or a function that starts from zero. Most “web” Python is both. Measure the command first. The server will still be there after lunch.

Spread The Article

Share this guide

Send this article to your network or keep a copy of the direct link.

X Facebook LinkedIn Reddit Telegram

Discussion

Leave a comment

No comments yet

Be the first to start the conversation.