Lazy imports
Generate Python code that defers module loading until first use. Pick the right mechanism based on the target Python version:
- Python >= 3.15: use the native
lazysoft keyword (PEP 810). - Python 3.9-3.14: use
wrapt.lazy_import()(requireswrapt >= 2.1.0).
Use this skill when the task is to change Python import behavior. Do not use it for generic import cleanup, dependency-only work, or startup tuning that does not hinge on import deferral.
Step 1 — determine the Python version
Check the project for version clues in this order:
python_requiresinpyproject.tomlorsetup.cfg.python-versionfile- The user's explicit statement ("I'm on 3.13", etc.)
- Runtime shebang or CI config
If the minimum supported version is >= 3.15, use the native syntax.
If it is 3.9-3.14, use wrapt. If it is < 3.9 or unknown,
ask the user before proceeding because this skill's guidance assumes
Python 3.9+.
Step 2 — write the imports
Python 3.15+ — native lazy keyword
# Whole module
lazy import numpy as np
# From-import
lazy from pathlib import Path
# Mix with eager imports freely
import argparse # eager — lightweight, needed immediately
lazy import torch # deferred until first use
lazy import pandas as pd # deferred until first use
Constraints enforced by the interpreter — produce a SyntaxError if
violated:
lazyimports are only allowed at module scope. Not inside functions, class bodies, ortry/except/finallyblocks.- Star imports cannot be lazy:
lazy from mod import *is illegal. - Future imports cannot be lazy:
lazy from __future__ import ...is illegal.
Errors such as ModuleNotFoundError surface at first use, not at the
import statement. The traceback includes both the use site and the
original import line.
Python 3.9-3.14 — wrapt.lazy_import()
Requires wrapt >= 2.1.0. Add it to the project's dependencies.
import wrapt
# Whole module
np = wrapt.lazy_import("numpy")
# Submodule via dotted path
pd_core = wrapt.lazy_import("pandas.core")
# Specific attribute from a module
dumps = wrapt.lazy_import("json", "dumps")
Key behaviours:
- Returns a
LazyObjectProxy— a transparent proxy that triggers the real import on first attribute access or call. - Does not patch
sys.modulesuntil the module is actually loaded. No global side effects. - For callable attributes, the proxy is automatically callable
(
wrapt >= 2.1). For non-callable objects with special dunder methods, passinterface=with the appropriatecollections.abctype. - Supports Python 3.9+.
Type checking with wrapt (Python 3.9-3.14)
wrapt.LazyObjectProxy is opaque to static type checkers — they cannot
see the real type behind the proxy. To restore full IDE support
(autocompletion, type inference, go-to-definition), always pair every
wrapt.lazy_import() call with a TYPE_CHECKING guard that performs the
real import statically. The TYPE_CHECKING constant is False at
runtime, so the eager import never executes — it exists purely for type
checkers and IDEs.
from __future__ import annotations # makes all annotations strings, avoiding runtime evaluation
import typing as ty
import wrapt
numpy = wrapt.lazy_import("numpy")
pd = wrapt.lazy_import("pandas")
dumps = wrapt.lazy_import("json", "dumps")
if ty.TYPE_CHECKING:
import numpy
import pandas as pd
from json import dumps
Rules:
- The names inside the
TYPE_CHECKINGblock must match the variable names bound bywrapt.lazy_import()so that type checkers associate the correct type with the correct name. - Use
from __future__ import annotationsat the top of the file. This ensures annotations are treated as strings and never trigger a runtime import of the type-checked module. - Keep the
TYPE_CHECKINGblock immediately after thewraptlazy imports so the pairing is obvious. - On Python 3.15+ this is unnecessary — the native
lazykeyword is understood by type checkers natively.
Step 3 — decide what should stay eager
Default to lazy. Only keep an import eager when one of these applies:
| Reason to stay eager | Example |
|---|---|
| Module has import-time side effects that must run immediately | logging.config, monkey-patching libs like gevent.monkey |
| Import is inside a try/except ImportError block for optional dependency detection — the point is to fail eagerly | try: import ujson except ImportError: import json |
| Module is trivially cheap and used on every code path | os, sys, typing, collections |
| The module is a __future__ import | from __future__ import annotations |
When generating code, add a short comment next to any eager import explaining why it is not lazy, so the intent is clear:
import sys # eager — trivial, always needed
lazy import numpy as np # deferred
Example transformations
"Lazily import numpy, pandas, and torch"
Python >= 3.15:
lazy import numpy as np
lazy import pandas as pd
lazy import torch
Python < 3.15:
from __future__ import annotations
import typing as ty
import wrapt
np = wrapt.lazy_import("numpy")
pd = wrapt.lazy_import("pandas")
torch = wrapt.lazy_import("torch")
if ty.TYPE_CHECKING:
import numpy as np
import pandas as pd
import torch
"Lazily import just the dumps function from json"
Python >= 3.15:
lazy from json import dumps
Python < 3.15:
from __future__ import annotations
import typing as ty
import wrapt
dumps = wrapt.lazy_import("json", "dumps")
if ty.TYPE_CHECKING:
from json import dumps
"Make this CLI's imports lazy"
Given existing code:
import argparse
import numpy as np
import torch
from rich.console import Console
Python >= 3.15 output:
import argparse # eager — trivial, always needed
lazy import numpy as np
lazy import torch
lazy from rich.console import Console
Python < 3.15 output:
from __future__ import annotations
import argparse # eager — trivial, always needed
import typing as ty
import wrapt
np = wrapt.lazy_import("numpy")
torch = wrapt.lazy_import("torch")
Console = wrapt.lazy_import("rich.console", "Console")
if ty.TYPE_CHECKING:
import numpy as np
import torch
from rich.console import Console