Skip to content

Restore the deferred imports to module scope with PEP 810 lazy imports #554

Open

Description

@jaraco

Six imports in this package sit inside the functions that use them, purely so that importing the package doesn't pay for them:

__init__.py from . import _adapters (python/cpython#109829)
__init__.py import csv
__init__.py import json (#503)
__init__.py from zipp.compat.overlay import zipfile (#502)
__init__.py import inspect (python/cpython#118761)
_compat/localize.py import importlib.metadata (python/cpython#109829)

Each one needs a comment explaining why it is where it is, and none of them appears where a reader looks to see what the module depends on. The performance reason is real, but it is the kind of thing the language should express rather than the author.

PEP 810 now does. In Python 3.15 a module that declares __lazy_modules__ gets lazy semantics for plain import statements, with no new syntax:

$ python3.15 -c 'import sys; print(sorted(sys.lazy_modules)[:4])'
['copy', 'copy.copy', 'glob', 'glob._StringGlobber']

So these imports can go back where they belong and stay deferred.

Aim

Restore all six to module scope, declared in __lazy_modules__, behaving the same way on every supported Python. Not a performance change: hold roughly the cost we have now, and accept a modest regression before 3.15 as the price of the readability.

Keeping the two trees close

This package is kept in sync with importlib.metadata, so the shape of the declaration matters as much as the behavior.

__lazy_modules__ is only a module global, so it is inert on 3.11–3.14 and needs no guard — the declaration itself is identical in both trees. Spelling the package through __name__ keeps the entries for this package's own modules identical too. Only the zipfile entry has to differ, alongside the import line that already differs for the zipp overlay.

Behaving the same before 3.15 does need @pganssle's lazy-importer, which emulates PEP 810 on 3.11+ through a context manager and defers to the native mechanism on 3.15+. That reference belongs behind compat/py314, in the shape the compat package already uses for version-conditional behavior, so the only traces in the shared module are one import line and one with statement — both removed at sync time, the way from .compat import py311 already is. The imports inside the block are re-indented, which is the one cost worth naming: each sync has to dedent them.

Cost

With all six restored, per the project's own pytest-perf exercise:

import time
3.11 +0.6 ms
3.12 +1.6 to +2.6 ms
3.13 +0.9 to +1.7 ms
3.14 +0.8 to +1.1 ms
3.15 +0.06 ms (+1%)

3.15 is free, as it should be — nothing but one extra compat/py314 module in the graph, and +1% across repeated runs. Before it, the whole cost is importing lazy_importer itself: its own module body, plus contextlib, threading and importlib.util. The deferrals it buys back are the ones we already had, so that import is the entire difference. Ranges are given because repeated runs on one machine disagree by that much; the figure is around 1 to 2 ms, not a tighter number than that.

Notes

  • from . import _adapters cannot be used: per PEP 810 that form names the package as the module to defer, and the package is mid-import, so it defers nothing. Worse, on the backport it recurses until the stack is exhausted. Importing the member instead (from ._adapters import Message) keys the declaration on _adapters and works on both.
  • inspect cannot be observed as deferred on 3.12 and 3.13, where importlib.abc — which this module imports eagerly — reaches it through importlib.resources._common. The declaration is still correct; the module is simply loaded for other reasons.

Activity

  1. jaraco commented on Oct 31, 2026

    @jaraco
    MemberAuthor

    #555 implements step 1. It lands better than the −6.1% projected above, because deferring compat (once _compat.localize stopped importing py312 at module scope) and platform turned out to matter more than the audit credited them for. Per pytest-perf: −30% on 3.15, and −8%/−3%/−4%/−8% on 3.11/3.12/3.13/3.14, since those two are ordinary deferred imports rather than lazy ones.

    One note for step 2: exercises.py:entrypoint_regexp_perf already calls re.match(EntryPoint.pattern, input), so the only in-repo consumer works whether pattern is a compiled pattern or a string. That narrows what a first-access-compiling descriptor would have to preserve.

  2. changed the title [-]Defer performance-sensitive imports with PEP 810 lazy imports[/-] [+]Restore the deferred imports to module scope with PEP 810 lazy imports[/+] on Oct 31, 2026
  3. jaraco commented on Oct 31, 2026

    @jaraco
    MemberAuthor

    Rewrote the description above: the original framing had this backwards, chasing import time rather than readability. The earlier measurements are superseded, and #555 is closed in favor of the scope now described.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions