On this page
Python 3.15.0 came out on October 9, 2026 with lazy imports (PEP 810). The tempting switch is -X lazy_imports=all, which makes every module-level import lazy at once. The risk, which I described in Python 3.15 for Django developers, is an import that exists only for its side effect: nothing uses the name, so the module never loads. Lifeguard, a static analyzer from Meta, finds those imports before your users do. I ran it on a small Django 6.1.2 project, broke a plugin registry with lazy_imports=all, and fixed it with a filter built from Lifeguard's report.
What Lifeguard is#
Lifeguard is an MIT-licensed tool from Meta, written in Rust on top of the ruff parser and parts of pyrefly. Version 0.4.0 was released on October 6, 2026, and the README still calls it beta. It is published on PyPI as lifeguard-lazy-imports, with prebuilt wheels for Linux, macOS and Windows, so you need Python 3.12 or newer and no Rust toolchain.
It never imports your code. It walks each module's syntax tree and looks for things that happen at import time: module-level function calls, decorators that register something, reads or writes of sys.modules, __del__ methods and exec(). The analysis is conservative: a module it cannot prove safe is marked unsafe. That leaves some speed on the table, which the README accepts in favor of production safety.
Run it on a Django project#
pip install lifeguard-lazy-imports
SITE=$(python -c 'import sysconfig; print(sysconfig.get_path("purelib"))')
lifeguard run-tree . lifeguard.json \
--site-packages "$SITE" \
--verbose-output lifeguard.txt \
--sorted-outputPass --site-packages. Lifeguard ships effect stubs for the standard library and a few popular packages, but not for Django, so without it every Django call made at import time counts as unknown. With it, Lifeguard follows your imports into Django and analyzes those files as well. On my project the first run read 25 files and passed 64 percent of them; with site-packages it read 275 files, 250 of them found through imports, in about half a second.
You can put the path in pyproject.toml instead. The table is a top-level [lifeguard], not [tool.lifeguard], and relative paths resolve against the directory you analyze:
[lifeguard]
site_packages = ".venv/lib/python3.14/site-packages"Two details I hit. run-tree skips files whose names are not valid Python identifiers, so migrations such as 0001_initial.py are not analyzed; Django loads them dynamically anyway. And the command exits with status 0 even when modules fail, so a CI job has to read the JSON itself. If your code already uses the lazy keyword, add --python-version 3.15, as the README says.
What it found#
The test project has two apps. orders has a model, an admin registration and a post_save signal connected in AppConfig.ready(). payments has a common plugin pattern: each provider module registers itself with a decorator, and a service module imports the providers only so that they register.
# payments/providers/__init__.py
PROVIDERS = {}
def register(name):
def decorator(cls):
PROVIDERS[name] = cls
return cls
return decorator
# payments/providers/card.py
from payments.providers import register
@register("card")
class CardProvider:
def charge(self, amount):
return f"charged {amount} by card"
# payments/services.py
from payments.providers import PROVIDERS
from payments.providers import card, upi # noqa: F401 imported to register them
def charge(method, amount):
return PROVIDERS[method]().charge(amount)The verbose report points at the decorator in each provider:
## payments.providers.card
### Errors
UnsafeDecoratorCall (1)
Line 4 - payments.providers.registerThe JSON goes one step further and ties the problem to the module that depends on it. LAZY_ELIGIBLE maps every module that is safe to load lazily to the modules that must already be imported first. My service module came back as "payments.services": ["payments.providers.card", "payments.providers.upi"]: safe, but only if both providers load eagerly. The verbose report listed no error for payments.services itself, so read the JSON as well.
Most other findings are not bugs. Model fields, admin.site.register(), the @receiver decorator and, inside Django, gettext_lazy() are import-time calls that Lifeguard cannot prove harmless, so my models.py, admin.py and signals module were all marked unsafe. Unsafe here means "loading this later than usual could change behavior", not "this is broken". Separately, LOAD_IMPORTS_EAGERLY named 8 Django modules, among them django.apps.registry and django.utils.module_loading. For these, every import inside the module has to run eagerly, because of patterns like sys.modules access and __del__ methods.
Watching the registry break#
To see the failure, I ran a script that sets up Django, creates an order and charges a card, in Docker's python:3.15.0rc3-slim image. On October 10, Docker Hub had no 3.15.0 image yet, and the commits between rc3 and the final release changed no lazy import code, only a comment in the What's New page.
$ python demo.py
signal fired for order 1
charged 10 by card
$ python -X lazy_imports=all demo.py
signal fired for order 1
KeyError: 'card'Under lazy_imports=all, from payments.providers import card, upi binds two lazy names that nothing ever reads, so the provider modules never run and the registry stays empty. manage.py check still passed in that mode: the failure appears only when the code path runs. The signal kept working because imports inside a function, such as the one in AppConfig.ready(), are never lazy.
Turn the report into a lazy imports filter#
Python 3.15 lets you veto individual lazy imports with sys.set_lazy_imports_filter(). The filter receives the importing module, the resolved name of the imported module and the fromlist, and returns False to make that import eager. The README says Lifeguard's output is meant to drive such a filter, but 0.4.0 ships no tool for it yet, so I wrote a small one. It needs the list of modules Lifeguard analyzed, because the JSON names only the safe ones:
lifeguard gen-source-db . source-db.json --site-packages "$SITE"import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
def module_name(path):
parts = path.removesuffix(".py").split("/")
if parts[-1] == "__init__":
parts.pop()
return ".".join(parts)
def install(report="lifeguard.json", source_db="source-db.json"):
result = json.loads((HERE / report).read_text())
build_map = json.loads((HERE / source_db).read_text())["build_map"]
analyzed = {module_name(path) for path in build_map}
safe = result["LAZY_ELIGIBLE"]
# Unsafe modules, plus the dependencies safe modules need loaded first.
eager_targets = (analyzed - safe.keys()) | {d for deps in safe.values() for d in deps}
eager_importers = set(result["LOAD_IMPORTS_EAGERLY"])
def lazy_filter(importer, name, fromlist):
if importer in eager_importers or name in eager_targets:
return False
return not any(f"{name}.{item}" in eager_targets for item in fromlist or ())
sys.set_lazy_imports_filter(lazy_filter)The rules are as follows. Modules Lifeguard marked unsafe, and the dependencies it listed for safe ones, are imported eagerly. Modules in LOAD_IMPORTS_EAGERLY import everything eagerly. Everything else stays lazy, including the standard library, which Lifeguard does not analyze. Install the filter before Django is imported, in manage.py and likewise in wsgi.py or asgi.py:
def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shop.settings")
import lazy_filter
lazy_filter.install()
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)With the filter in place, the same lazy_imports=all run printed charged 10 by card again, and the signal still fired.
What it costs at startup#
The filter makes more imports eager, so some of the speed-up goes away. I measured manage.py check in the rc3 container with Django 6.1.2 and this two-app project: 3 warm-up runs, then 25 interleaved runs per mode on an Intel Core 5 210H laptop.
| Mode | Median time | Modules loaded | Registry |
|---|---|---|---|
| Normal imports | 419 ms | 657 | Works |
-X lazy_imports=all | 279 ms | 409 | Broken |
all plus the Lifeguard filter | 352 ms | 524 | Works |
In my test the filter kept about half of the gain: 67 ms saved instead of 140 ms, with the registry intact. These are startup numbers. They matter for management commands, tests, autoreload and cold starts, not for request latency. My earlier Python 3.15 post measured a fresh project with no apps of its own on a different setup, so compare the rows of this table with each other, not with that post.
How I would roll it out#
- Run Lifeguard on the project with
--site-packagesand read the verbose report for your own modules. Skip the noise from model fields and admin registrations. - Fix the real side-effect imports. Move signal imports into
AppConfig.ready(), and give registries an explicit loader, for exampleimportlib.import_module()over a list of provider names. Dynamic imports stay eager under PEP 810. - Turn on
-X lazy_imports=allwith the filter in development and in the test suite, and run the whole suite, including the tests that exercise your registries and signals. - Only then try it in production, and keep the plain mode one environment variable away.
Lifeguard is young. Only two issues have been filed so far, and the official way to feed its output into a filter is still on the roadmap. As a report generator it already earns its place: it found the one real problem in my project in half a second. I would not gate deploys on it yet. If you also run AI agents in your Django apps, the same week brought eight Pydantic AI security advisories worth checking first.
Frequently asked questions#
What is Lifeguard for Python?
Lifeguard is an open-source static analyzer from Meta that finds code incompatible with PEP 810 lazy imports, such as modules that register themselves when imported. It is installed with pip install lifeguard-lazy-imports and run with lifeguard run-tree.
Does Lifeguard need Python 3.15?
No. It runs on Python 3.12 or newer and analyzes source code without importing it. Pass --python-version 3.15 if your code already uses the lazy keyword.
Why does Lifeguard mark my Django models.py as unsafe?
Model fields, admin registrations and signal receiver decorators are calls that run at import time, and Lifeguard marks any module it cannot prove safe as unsafe. That is conservative, not a bug report; the modules still load when you use a name from them.
How do I use Lifeguard output with sys.set_lazy_imports_filter?
Make imports eager when the target is a module Lifeguard analyzed but left out of LAZY_ELIGIBLE, or a dependency listed for a safe module, and make every import inside LOAD_IMPORTS_EAGERLY modules eager. Lifeguard 0.4.0 does not generate this filter for you yet.
Do Django signals break with lazy imports?
Only when the signals module is loaded by a module-level import whose name nothing uses. Signal modules imported inside AppConfig.ready() fired in every mode in my test, because imports inside functions are never lazy.
Comments
No comments yet. Be the first.