# Working to Make Python Lazy

> Source: <https://iscinumpy.dev/post/flake8-lazy/>
> Published: 2026-09-01 18:44:28+00:00

Python 3.15a7, which is now just a `uv python install 3.15`

away on all major
platforms, has lazy imports! This exciting feature, proposed in [PEP 810](https://peps.python.org/pep-0810),
promises to make CLI applications faster (especially when using flags like
`--help`

), and could make a lot of large code with lots of imports that don’t
always get used faster too. Unlike the earlier, failed attempt, this requires
libraries to put in some work. I’ve developed a helper tool to make it easy; I’d
like to cover what lazy imports are and how to use my tool. Since this is the
first library that I used AI heavily in developing, the second half of the post
will cover how my experience with AI for a task like this went.

TL;DR: run `uvx flake8-lazy --apply=list`

to make your code magically faster on
Python 3.15!

## What is a lazy import?

Imagine you have a file like this, with a standard Python argparse CLI:

``` python
import argparse
import numpy

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--foo", action="store_true")
    args = parser.parse_args()
    if args.foo:
        print(numpy.array([1, 2, 3]))
```

What happens if you run this with `--help`

? The `numpy`

library will be
imported, even though it is never used. If you are using modern `uv`

tooling,
this can be even worse, since `uv`

doesn’t pre-compile bytecode unless you ask
it to; that makes the install faster, but imports are slower the first time.

The above is just one example; this can also happen when you have this common pattern:

``` python
# __init__.py
from . import a
from . import b

__all__ = ["a", "b"]
```

The idea behind this is that a user can just use `lib.a.stuff`

with just
`import lib`

, rather than `import lib.a`

, but you pay the cost of import even if
they never use all the imports. Some libraries, like `rich`

, are careful to
avoid this and ask users to import explicitly, but many older libraries did
this.

And there are also libraries that can do multiple things (like CLI libraries with subcommands), but you don’t need the dependencies for every subcommand.

## How to use Python 3.15’s lazy imports

Take the first example. In Python 3.15, you can now write:

``` python
lazy import argparse
lazy import numpy

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--foo", action="store_true")
    args = parser.parse_args()
    if args.foo:
        print(numpy.array([1, 2, 3]))
```

Now, both imports are “lazy”, meaning nothing happens at all when you import
them. They might not even be installed. The first time you try to use the
object, though, it becomes a real, imported object. So if you do `--help`

,
`numpy`

is never accessed and never imported.

There is also a backward-compatible syntax:

``` python
__lazy_modules__ = ["argparse", "numpy"]

import argparse
import numpy
```

This works on older Pythons (it’s just not lazy), and you can also dynamically generate or manipulate that list if you want. Linters like Ruff have already updated to allow this to be placed above your imports without triggering a lint violation.

I should mention there’s a flag and a variable to make Python treat all imports
as lazy, `-X lazy_imports=all`

and `PYTHON_LAZY_IMPORTS=all`

(also `normal`

and
`none`

). That’s mostly for testing.

`none`

doesn’t disable
`__lazy_modules__`

; it only disables the syntax version!
[Will be fixed](https://github.com/python/cpython/pull/146371).

`none`

should *only*disable the back-compat

`__lazy_modules__`

variation; `lazy import`

should be a guaranteed lazy import.
The existence of this option as it stands is currently blocking use in the
standard library.## Why not lazy?

Shouldn’t you just mark everything as lazy? You don’t have to. There are some modules that have side effects when you import them; if those side effects need to happen at the import site, then those can’t be lazy. This pattern, for example, can’t be lazy:

``` python
try:
    import numpy
except ModuleNotFoundError:
    ...
```

The error here will move to the first usage of something from `numpy`

. There is
a semi-lazy alternative:

``` python
import importlib.util

if importlib.util.find_spec("numpy") is None:
  ... # whatever you wanted to do if numpy is missing

lazy import numpy
```

This is slightly more expensive than doing nothing at all (which is why lazy
importing doesn’t do it), will import packages to get to subpackages (`a.b`

imports `a`

), and some types of import errors won’t trigger when just finding
the spec (for the above example, `numpy._core`

could be missing/broken if
someone didn’t compile numpy correctly - this is rare, though). Regardless, this
is a pretty good way to check to see if a package is installed.

The other case you don’t need lazy is if you use something at top level. For example:

``` python
lazy import re

REGEX = re.compile(...) # not lazy here
```

Here, the lazy import is not needed, since you can’t process the file without importing this anyway. You can work around this by caching:

``` php
import functools
lazy import re

@functools.cache
def regex() -> re.Pattern:
  return re.compile(...)
python
from __future__ import annotations

__lazy_modules__ = ["re"]

import functools
import re

@functools.cache
def regex() -> re.Pattern:
    return re.compile(...)
```

Notice I don’t need `from __future__ import annotations`

to make this work; the
annotation doesn’t cause the `re`

module to be loaded because in Python 3.14
annotations became lazy by default in that version.

You *can* make these sorts of imports lazy, but you are just moving the import
errors for no good reason, so it’s a bit better not to.

If you want to make everything in a file lazy, you can do it like this:

``` php
class AllLazy:
    @staticmethod
    def __contains__(_: str) -> bool:
        return True

__lazy_modules__ = AllLazy()
```

This simply is used by testing with `in`

on full module names, and you can put
your own object in here. (The static tool below doesn’t look for this yet.)

## A tool to help

So libraries ideally should start adding these `__lazy_modules__`

, but it’s a
little more complex than just putting all modules into it. So I wrote a tool,
[flake8-lazy](https://github.com/henryiii/flake8-lazy), to help with figuring out exactly what to add, and with keeping
it tidy. This is the first library I’ve used AI tools heavily in developing
(I’ve started using them to help maintain plumbum, but that’s not from scratch),
so I’ll end with a section about how that went (very well). I’ve developed
[flake8-errmsg](https://github.com/henryiii/flake8-errmsg) in the past, so it’s not my first flake8 plugin. Like that
project, there’s also a built-in standalone runner; early in the 3.15 lifecycle,
I rather expect that to be the main way to use it.

To use it:

```
# Show flake8-style errors
uvx flake8-lazy <filenames>
# Show the lines you need to add
uvx flake8-lazy --format=lazy-modules
# Just add it!
uvx flake8-lazy --apply=list <filenames>
# Show flake8-style errors
pipx run flake8-lazy <filenames>
# Show the lines you need to add
pipx run flake8-lazy --format=lazy-modules
# Just add it!
pipx run flake8-lazy --apply=list <filenames>
```

This will report the errors (`noqa`

doesn’t work with the simple runner).

Here are the errors currently implemented (0.6.0):

| Code | 1xx: Missing lazy declarations |
|---|---|
`LZY101` | stdlib module should be listed in `__lazy_modules__` |
`LZY102` | third-party or local module should be listed in `__lazy_modules__` |

These try to find things that are not used at top level, and suggest they be
added to your `__lazy_modules__`

(the `lazy`

syntax works too). Currently, they
assume annotations do not trigger an import (since flake8, unlike Ruff, doesn’t
know the minimum Python version you are targeting, it can’t tell if it’s 3.14+
or not).

| Code | 2xx: `__lazy_modules__` validation |
|---|---|
`LZY201` | `__lazy_modules__` is not sorted |
`LZY202` | module listed in `__lazy_modules__` is never imported |
`LZY203` | module listed in `__lazy_modules__` is duplicated |
`LZY204` | `__lazy_modules__` is assigned after importing modules it names |
`LZY205` | module listed in `__lazy_modules__` must be an absolute name |

These look for general problems specifically with `__lazy_modules__`

.

| Code | 3xx: Native `lazy` keyword (Python 3.15+) |
|---|---|
`LZY301` | lazy import inside `suppress(ImportError)` is misleading |
`LZY302` | module declared lazy by both `lazy` keyword and `__lazy_modules__` |
`LZY303` | module imported both eagerly and lazily |

These look for issues specific to Python 3.15+’s new syntax. These only work on
3.15+ as the host Python, as well. You can tell uv to use it already with
`--python=3.15`

.

| Code | 4xx: Lazy import safety and semantics |
|---|---|
`LZY401` | module is declared lazy but accessed at the top level |
`LZY402` | module is an enclosing package for this file and should not be lazy |

`LZY401`

is the opposite of the `LZY101`

/`LZY102`

checks, basically; if you
access something at top level, you might as well not make it lazy. This might
get moved to a 9xx check, as it’s not problematic to do it, and the check system
could be wrong.

## Tips

Don’t apply this to test suites.

Look for opportunities to make things lazy if they are not listed here. The `re`

example above is an example of this. But also check the *actual* imported
libraries, too - one library may import another anyway (quite a few libraries
import `re`

, including `typing`

, making that one really hard to avoid! `re`

is
pretty slow, too, sadly). You can do this with `-X importtime`

. Anything that is
lazy and never gets imported will not show up here anymore. You can force lazy
imports off to see the difference. You can also force lazy imports on to see how
much time you might save before starting.

Type checkers always treat `TYPE_CHECKING`

as `True`

, so you can avoid importing
typing with this trick:

```
TYPE_CHECKING = False
if TYPE_CHECKING:
    ...
```

With Ruff, you can even enforce this with the `TID251`

check:

```
[tool.ruff.lint.flake8-tidy-imports.banned-api]
"typing.TYPE_CHECKING".msg = "Use TYPE_CHECKING=False instead"
```

The `__lazy_modules__`

system is completely dynamic (just needs a `__contains__`

method for absolute module names); the checks don’t handle anything dynamic
here. The most common use case, relative imports, can be left static:

``` python
__lazy_modules__ = [f"{__spec__.parent}.thing"]
from . import thing
```

Note that `__package__`

is the older form of `__spec__.parent`

. Don’t use this
on `__main__.py`

, use absolute imports on that one (mypy will notice that
`__spec__`

can be `None`

on this file).

## Results

I tried running this tool on its own source code, and managed to get the
`--help`

flag 2x faster on Python 3.15. On cibuildwheel,
[this managed](https://github.com/pypa/cibuildwheel/pull/2797) a 3-4x speedup
for things like `--help`

and `--print-build-identifiers`

. Hugo, the release
manager for Python 3.14, was able to get a bit more speed
[in a PR to my PR](https://github.com/henryiii/cibuildwheel/pull/17). Here are
some of the results I’ve gotten so far; for each case, I’m checking `--help`

,
though other things can get faster too. Due to the bug mentioned in 3.15a7, the
before timing sometimes uses 3.14.

| package | Before | After | Speedup | Notes |
|---|---|---|---|---|
| flake8-lazy | 100+ ms | 50 ms | 2x | Original speedup (current version is a little faster than original) |
| repo-review | 113 ms | 35 ms | 3x |
|

[PR](https://github.com/pypa/cibuildwheel/pull/2797)[PR](https://github.com/pypa/packaging/pull/1129), no`--help`

to test[PR](https://github.com/henryiii/check-sdist/pull/152)100+ ms is noticeable, getting under that makes your app feel snappier. Python itself takes about 15 ms (on my M1), so you can’t get faster than that (and you likely at least need a few things, like argparse).

You can see the impact of each library, and your success/failure to reduce
imports, with `-X importtime`

.

If you want to time this yourself, use hyperfine:

```
hyperfine --warmup 10 \
     -n "main" --prepare "git checkout main"        "python3.15 -m <pkg> --help" \
     -n "PR"   --prepare "git checkout some-branch" "python3.15 -m <pkg> --help"
```

You can do just one run, and pass `-X lazy_modules=none`

or `all`

as well.

Keep in mind, `uv`

and some other tools don’t compile bytecode by default, which
means you might be saving a lot more for a first-run cost than the measurements
above. Some of the above results could get better if third-party libraries or
the standard library add lazy imports.

There’s still a ways to go - there are lots of edge cases in trying to detect if
something is being resolved. For example, dataclasses resolve type hints to see
if `typing.ClassVar`

is used, which breaks laziness. It’s better to put too much
into lazy than too little.

There’s also a big problem with this syntax:

``` python
from a import b
```

Is `a.b`

a module or not? Only a type checker knows (if it’s typed). This is the
same thing again:

``` python
from . import b
```

I had to assume the right hand side is not a module, but if it is, it will be
missed. You can use `as`

to avoid this ambiguous syntax.

Also, maybe it’s obvious, but most of the big, slow imports like numpy aren’t being built for CPython 3.15 yet (around the first RC is when compiled wheels can be published), so some of the most exciting improvements in time can’t be tested yet.

## Developing the tool with AI

This was a really interesting project to try AI on, partially because this has
never been done before. Lazy imports were added quite recently, were just
released about a week ago for the first time in an alpha build of CPython, and
have only been easily available in uv for three or so *days*. The AI can’t be
just grabbing some existing code because it doesn’t exist (I know that’s not how
model training and validation works). It has to take my input, run tests, and
read the PEP, and “reason” from that. And it *does*. I used it on over 40 tasks,
and it never failed to understand what I asked it to do. It didn’t “outsmart” me
and do something smarter than I would have done, but it followed directions
perfectly. Not only did I not hand write more than about 5% of the code (mostly
tweaks and configuration), but I haven’t followed through all the implementation
details. It took less than a day for the initial version (I was doing other
things too while the AI worked), and getting it into a usable form (by using it
on libraries) happened over the next couple of days (again, off and on). This is
probably 5-7x faster than I could have done it by hand. Code is a bit longer
than a hand written solution, mostly due to duplication (I could iterate to make
sure it was clean/readable, it still looks mostly like my code). Check
[repo-review](https://github.com/scientific-python/repo-review) to see what my hand designed code looks like.

I started with my
[Scientific Python Development Guide](https://learn.scientific-python.org/development/)’s
template, which has strong linting, formatting, and testing setup already,
perfect for AI usage. I tried a few options I haven’t used before, like
`uv_build`

for the backend, and the new `Zensical`

documentation engine. I also
ended up finding a few things that could be improved, and put them back into the
template. I also increased the linting checks to `ALL`

then used
`uvx --from sp-repo-review[cli] sp-ruff-checks .`

to get a list of checks that
are always good to ignore. I think a *lot* of the success of the AI came down to
just how good this setup is.

For AI, I’m using [GitHub Copilot](https://github.com/features/copilot), with
auto model selection, primarily in VSCode (though I also later used the GitHub
agent feature too to develop features in parallel). The model seemed to mostly
be GPT-5.3-codex, though Claude Sonnet 4.6 was auto-selected sometimes too.

I didn’t add configuration at first, but once I filled up my first context
window and wanted to start a new chat, I added a handwritten `AGENTS.md`

and a
copilot CI configuration (`.github/workflows/copilot-setup-steps.yml`

). The
focus of these was to make `uv`

, `prek`

, and `nox`

available and instruct the
tools to use them. This reduced my need to manually run these or tell the agent
about them.

I ended up doing very little manual coding - most of my manual edits were setup or configuration. If I didn’t like the model output, I just would ask for it to make changes. I was quite explicit though in instructions; I’ve written a plugin for flake8 with a manual runner before, so I knew what I wanted. And I iterated a lot. For example, when adding better error messages for broken files, the model thought about adding Python 3.11 exception notes, but then did it a different way, due to Python 3.10 being the minimum. I asked it to instead do the notes, but gate it for 3.11.

The docs were initially written by the model too, though there I did quite a bit of editing as well. I don’t love the repetition between the docs and README, but the agent is pretty good at keeping them in check, even if I edit one, it can fix the other to match. I don’t see a way to include one in the other with Zensical yet.

I even did things like ask to rebase and solve the merge conflicts. It didn’t
fail at anything, really. The worst it did was not always run the style checks,
meaning I had to do one more thing once the CI caught the failing checks. But
even that was pretty rare. I asked it to refactor the really long `__init__.py`

file eventually, and it did that perfectly too; the only thing it didn’t do was
re-apply `__lazy_modules__`

.

I really couldn’t be much happier with the *results*. The agent was great at
writing tests for everything it added, even without prompting. It would work
through errors and warnings - I didn’t have to do any of that, which was
fantastic. I ran on the CPython source code, and found an issue (the encoding
wasn’t handled), so I just told the agent how to run it, and it found the issue
and applied the correct fix (use the tokenizer rather than manually opening the
file, which I would have taken much longer to find). I asked it to handle
relative imports correctly (`.`

), and it generalized for `..`

, etc.

I also asked it to read [PEP 810](https://peps.python.org/pep-0810), and look for possible checks based on the
text, which it did a great job with, and some of the checks are actually from
those suggestions. I also asked it to come up with a better numbering scheme,
which it also did.

Refactors were amazing. I could just make big changes, like reorganizing the numbering scheme, and then it would just tinker for a while and then it was done. I tried making this multithreaded on free-threaded Python (the CLI runner, that is), but got pretty poor results; something is creating a single-threaded bottleneck, and wasn’t able to find it quickly. But being able to try big things like that very easily was great. And this still wasn’t a “mistake”, it did exactly what I wanted.

Iteration on stuff that takes times was a strong point. If you have a tool that outputs something, rather than fixing it itself, the agent is really good at applying a fix (including complex things like typing) and rerunning. This continues to make linting tools even more valuable.

The code *quality* is not terrible, but hand written would be better, I think.
I’ve generally seen that - in the past, I’ve used AI for a quick first draft to
see if something is performant, etc, but will do a hand written implementation
for the actual PR. AI also can do cleanup if you ask it to, and telling it the
minimum Python version, that you value modern readable code, etc all helps. But
that begs the question; if the tests and linting are strong, and if you use AI
to edit it in the future, does human readability matter as much now? Also, does
that lock you into using AI tools? (I made sure the quality wasn’t that bad, but
interesting philosophical questions nonetheless.)

If you’d like to see what the work looked like, you can see the commit history
and the GitHub Agent PRs. Overall, it’s quite incredible, comparing my attempts
at AI early last year (just slop), last advent of code in TypeScript (great for
learning a language, and actually pretty good at refactoring and helping), and
now just *3 months* later, where it’s really, really good. I’ve tried to get it
to do pattern matching before; it was terrible, and now it actually gets it
correct (still has to be asked, though). It doesn’t always write ideal patterns,
but that’s easy to clean up if it’s correct. It’s still a tool that does what
it’s told, but it’s gotten *good* at doing what it’s told. Combined with proper
linting and testing setups (critical!), it’s a *very* good helper.

The skill set required to work with it, I believe, is the same. I am still doing the sort of high level things I’d do when designing a library. When I added generic typing to boost-histogram, I did one by hand, then told AI to do the rest following my example. I’m making the decisions, it’s just now a lot faster (as in, less of my time, I am doing other things while it’s working) to see the result of those decisions.

By the way, the 0x token models (I tried GPT 5 mini) work fine at taking the
output of `flake8-lazy --format=lazy-modules`

and applying them to a non-trivial
codebase automatically. It’s a bit slow, but it works, I used that on
`cibuildwheel`

initially. So I added a `--apply`

feature to the CLI to inject
the lines in 0.4.0. Now (in 0.6.0) it supports several formats; `list`

, `set`

,
`native`

, and `dynamic`

.

[Python](/categories/python/)
