Blog / Coding tips

Python Lazy Imports: How the New lazy Keyword Works in Python 3.15

Cover image for Python Lazy Imports: How the New lazy Keyword Works in Python 3.15

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:

  1. Run python3.15 -X importtime app.py 2> imports.txt and look for the biggest cumulative times.
  2. Try -X lazy_imports=all and time the startup again to see the upper limit of what you can gain.
  3. Mark the slow, rarely used imports with lazy, starting with ones only needed in one command or code path.
  4. 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.

// note

How to read this note.

This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.

If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.

Related posts

Python frozendict: The New Immutable Dictionary in Python 3.15 (Tested)
Coding tips

Python frozendict: The New Immutable Dictionary in Python 3.15 (Tested)

Python 3.15 adds frozendict, a built-in immutable, hashable dictionary. Tested examples: creating one, why you can’t change it, using it as a dict key or in a set, merging with |, isinstance checks and a fallback for older Python.

October 12, 2026 · 11 min read · 0 views

JavaScript Debounce Function Example, Explained
Coding tips

JavaScript Debounce Function Example, Explained

A ten-line JavaScript debounce function with real output: fast typing turned into one search, keeping this, cancel(), and a leading-edge version for buttons.

October 4, 2026 · 9 min read · 7 views

Python enumerate(): How to Start at 1 (and 12 Tested Examples)
Coding tips

Python enumerate(): How to Start at 1 (and 12 Tested Examples)

enumerate(items, start=1) gives you a counter that starts at 1 alongside each item. Tested examples for lists, dicts, zip, reversed order, file line numbers and comprehensions, plus why it beats range(len()) and the list.index() trap.

October 11, 2026 · 11 min read · 1 view