Python 3.15 adds explicit lazy imports: write lazy import json (or lazy from pathlib import Path) and Python won’t load the module until your code first uses that name. You keep all your imports neatly at the top of the file, but you only pay the loading cost for modules a particular run actually needs. In my test, a script that imported a slow module started in 0.50 seconds with a normal import and 0.00 seconds with lazy import, and the module loaded later, only when it was called.
Lazy imports come from PEP 810 and are one of the biggest changes in Python 3.15.0, released on 9 October 2026. I tested everything below on Python 3.15.0rc3 (installed with uv python install 3.15), and pasted the real output, including the error messages. The behaviour matches the final release notes.
Why imports slow Python down
When Python imports a module, the What’s New in Python 3.15 guide explains, it must locate the file, read it from disk, compile it to bytecode and execute all its top-level code. For applications with deep dependency trees, that can take seconds, “even when most of the imported code is never actually used during a particular run.”
Command-line tools feel this most. Typing mytool --help shouldn’t load your database driver, plotting library and HTTP client, but with normal imports at the top of the file, it does.
Developers have worked around this for years:
- Moving
import statements inside functions.
- Using
importlib.import_module() on demand.
- Restructuring code to avoid importing heavy packages.
They all work, but they scatter imports through the code and make it harder to see what a module depends on. The lazy keyword fixes that without moving anything.
The syntax: lazy import and lazy from … import
lazy goes in front of an ordinary import statement. Both forms work:
lazy import json
lazy from pathlib import Path
lazy is a soft keyword, which means it’s only special at the start of an import statement. Existing code that uses lazy as a variable name keeps working; lazy = 5; print(lazy) printed 5 on 3.15.
Older versions don’t understand the syntax at all. Running the same file on Python 3.14 gives:
File "./app_lazy.py", line 2
lazy import heavy
^^^^^^
SyntaxError: invalid syntax
So the keyword form is only for code that will run on 3.15 or later. There’s a compatible alternative later in this post.
Measuring the startup difference
To see the effect clearly, I made a fake “heavy” module that sleeps for half a second when it loads, standing in for a big library:
# heavy.py
import time
print(" [heavy] loading...")
time.sleep(0.5)
def report():
return "report ready"
First, a normal import:
# app_eager.py
import time; t = time.perf_counter()
import heavy
print(f"started in {time.perf_counter() - t:.2f}s")
[heavy] loading...
started in 0.50s
Now the lazy version, which also checks sys.modules (Python’s registry of loaded modules) before and after first use:
# app_lazy.py
import time; t = time.perf_counter()
lazy import heavy
print(f"started in {time.perf_counter() - t:.2f}s")
import sys
print("in sys.modules?", "heavy" in sys.modules)
print(heavy.report())
print("in sys.modules?", "heavy" in sys.modules)
started in 0.00s
in sys.modules? False
[heavy] loading...
report ready
in sys.modules? True
The import line cost nothing. The module only loaded at heavy.report(), and from then on it’s a completely normal module. If a run never touches heavy, it never loads at all.
What a lazy import actually creates
Behind the scenes, a lazy import binds the name to a lightweight proxy object. The proxy type is available as types.LazyImportType, and sys.get_lazy_imports() tells you the current global mode:
import types, sys
lazy import heavy
lazy from json import dumps
print(type(globals()["heavy"]).__name__, isinstance(globals()["heavy"], types.LazyImportType))
print(sys.get_lazy_imports())
print(dumps({"ok": True}))
print(type(globals()["dumps"]).__name__)
lazy_import True
normal
{"ok": true}
function
I peeked through globals() on purpose, because simply writing heavy would trigger the load. After dumps() was first called, the name was replaced by the real function. The mode normal is the default: only imports marked lazy are lazy.
Where lazy imports aren’t allowed
The 3.15 documentation lists three restrictions, and each one gives a clear SyntaxError. I tried all of them.
Inside a function (or class body):
def f():
lazy import json
SyntaxError: lazy import not allowed inside functions
Inside try/except (or finally):
try:
lazy import json
except ImportError:
pass
SyntaxError: lazy import not allowed inside try/except blocks
Star imports (and from __future__ imports):
lazy from json import *
SyntaxError: lazy from ... import * is not allowed
The try/except rule makes sense once you think about it: a lazy import doesn’t run at that line, so an except ImportError around it could never catch anything. If you need an optional dependency with a fallback, keep using a normal import inside try.
How errors show up with lazy imports
Because nothing is loaded at the import line, a missing module isn’t reported there. The documentation says the exception is raised at the point of first use, with a traceback that includes both places. Here’s what that looks like:
lazy import does_not_exist
print("import line passed")
does_not_exist.run()
import line passed
Traceback (most recent call last):
File "./app_missing.py", line 1, in <module>
lazy import does_not_exist
ImportError: lazy import of 'does_not_exist' raised an exception during resolution
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "./app_missing.py", line 3, in <module>
does_not_exist.run()
^^^^^^^^^^^^^^
ModuleNotFoundError: No module named 'does_not_exist'
The program printed “import line passed” before failing. That’s the main trade-off of lazy imports: typos in module names and missing dependencies surface later, possibly in a code path your tests don’t cover. Run your test suite with good coverage, and consider a quick smoke test that imports everything eagerly (see the global mode below).
Making every import lazy: -X lazy_imports and PYTHON_LAZY_IMPORTS
You can switch the behaviour on for a whole program without editing any source, using either the -X lazy_imports command-line option or the PYTHON_LAZY_IMPORTS environment variable. Both accept all (make all imports lazy by default) or normal (the default, respecting only the keyword).
With this plain file:
# app_all.py
import heavy
print("all-mode: started")
print(heavy.report())
$ python3.15 -X lazy_imports=all app_all.py
all-mode: started
[heavy] loading...
report ready
$ PYTHON_LAZY_IMPORTS=all python3.15 app_all.py
all-mode: started
[heavy] loading...
report ready
The ordinary import heavy became lazy: “started” printed before the module loaded. This is a quick way to measure how much your app would gain before you change any code. Some libraries rely on side effects at import time (registering plugins, patching other modules), so test carefully before turning all on in production.
You can also change the mode at runtime with sys.set_lazy_imports() and read it with sys.get_lazy_imports().
Choosing which modules are lazy with a filter
For finer control, sys.set_lazy_imports_filter() takes a function that decides per import. It receives the importing module’s name, the imported module’s name and the fromlist, and returns True to allow lazy loading. Here only heavy is allowed to be lazy, and json is forced to load eagerly:
import sys
def only_heavy(importing, imported, fromlist):
return imported == "heavy"
sys.set_lazy_imports_filter(only_heavy)
sys.set_lazy_imports("all")
import heavy
import json
print("heavy loaded?", "heavy" in sys.modules, "| json loaded?", "json" in sys.modules)
heavy loaded? False | json loaded? True
The documentation suggests a similar pattern for making only your own application’s modules lazy (for example, everything starting with myapp.) while keeping third-party libraries eager.
Supporting Python 3.14 and older: lazy_modules
If your code must still run on older versions, you can’t use the keyword, because it’s a syntax error there. Instead, define __lazy_modules__ at the top of the module: a list of fully qualified module names. On 3.15, normal imports of those modules become lazy; on older versions the variable is just an unused list and the imports run eagerly as before.
__lazy_modules__ = ["heavy"]
import heavy
print("compat: started")
print(heavy.report())
compat: started
[heavy] loading...
report ready
That’s the best of both worlds for libraries: faster startup on 3.15, no breakage on 3.14.
A real-world example: a command-line tool
Here’s the situation lazy imports were designed for. This small tool has two sub-commands. Only report needs the slow module, but with a normal import, version would pay for it too:
# tool.py
import sys
lazy import heavy
def main(argv):
if argv[:1] == ["version"]:
print("tool 1.0")
elif argv[:1] == ["report"]:
print(heavy.report())
else:
print("usage: tool.py [version|report]")
main(sys.argv[1:])
I timed both commands from the shell:
$ time python3.15 tool.py version
tool 1.0
real 0.01s
$ time python3.15 tool.py report
[heavy] loading...
report ready
real 0.51s
The version command never loads heavy, so it returns almost instantly. The report command loads it when it’s needed. The import still sits at the top of the file, where anyone reading the code can see every dependency.
Lazy imports vs importing inside a function
The traditional workaround is to put the import inside the function that needs it:
import time, sys
def make_report():
import heavy # old trick: import inside the function
return heavy.report()
t = time.perf_counter()
print("started", f"{time.perf_counter() - t:.2f}s", "heavy" in sys.modules)
print(make_report())
started 0.00s False
[heavy] loading...
report ready
It works on every Python version and gives the same startup saving, so it’s still a good choice for code that must support 3.14 and earlier. The downsides are readability and repetition: dependencies are hidden inside functions, and if three functions need the module, you write the import three times. With lazy import the dependency is declared once, at the top, and loaded once, on first use.
There’s also importlib.util.LazyLoader, which has existed for years, but it needs several lines of setup code per module. The new keyword replaces that boilerplate for most everyday cases.
How to measure your own import time
Before optimising, find out which imports are actually slow. Python has had a built-in import profiler for years: python -X importtime your_script.py prints how long each module took to import (in microseconds), with nested imports indented. Sort by the cumulative column and the expensive ones stand out. Those are your best candidates for lazy.
A rough checklist I’d follow:
- Run
python3.15 -X importtime app.py 2> imports.txt and look for the biggest cumulative times.
- Try
-X lazy_imports=all and time the startup again to see the upper limit of what you can gain.
- Mark the slow, rarely used imports with
lazy, starting with ones only needed in one command or code path.
- Run your tests, and check that import-time side effects (such as plugin registration) still happen when needed.
When lazy imports help (and when they don’t)
They help most in command-line tools with many sub-commands, web apps with rarely used admin features, test suites where each test touches a few modules, and serverless functions where cold-start time costs money.
They don’t help when every run uses every import anyway; you only move the cost later. They also won’t speed up code inside a hot loop, because after first use a lazily imported module is a normal module.
They can surprise you when a module does something important at import time. If import myapp.plugins registers handlers, making it lazy means those handlers don’t exist until something touches myapp.plugins. Keep such imports eager.
For file and path checks at startup, which often go hand in hand with deciding what to load, my guide to checking if a file exists in Python covers pathlib, which itself is a good candidate for lazy from pathlib import Path in small scripts.
FAQ
What is a lazy import in Python?
A lazy import delays loading a module until the imported name is first used. In Python 3.15 you write lazy import module or lazy from module import name, and Python binds a lightweight proxy that loads the real module on first access.
Which Python version supports the lazy keyword?
Python 3.15 and later, released on 9 October 2026 (PEP 810). On Python 3.14 and earlier, lazy import is a SyntaxError. Use __lazy_modules__ if your code must run on both.
Can I use lazy import inside a function?
No. Lazy imports are only allowed at module level. Using lazy inside a function, class body or try/except/finally block raises a SyntaxError. A normal import inside a function is already effectively lazy.
What happens if a lazily imported module doesn’t exist?
The import line succeeds, and the error appears when you first use the name. Python raises an ImportError chained to the original ModuleNotFoundError, with a traceback showing both the import line and the line where you used it.
How do I make all imports lazy?
Run Python with -X lazy_imports=all or set the environment variable PYTHON_LAZY_IMPORTS=all. You can also call sys.set_lazy_imports("all") at runtime, and use sys.set_lazy_imports_filter() to exclude specific modules.
Do lazy imports make my code faster?
They make startup faster when some imports aren’t needed on every run. They don’t speed up code that runs after the module is loaded, and if every import is used on every run, the total time stays about the same.
Is lazy a reserved word now?
No. It’s a soft keyword, only special at the start of an import statement. Variables named lazy still work.