TRENDING
Close-up of the Rosetta Stone showing the Demotic script above and the Greek script below, the same text written in two different scripts
October 6, 2026
How to Prepare Your Python Code for the Python 3.15 UTF-8 Default and Fix Windows Encoding Bugs
A row of green and grey fibre broadband street cabinets on a pavement beside a fence in Iver, England
October 6, 2026
BT’s TalkTalk Rescue Turns Telecom Continuity Into a New Merger-Control Ground
An ornate cast-iron wall mailbox with its door hanging open, stuffed with colorful flyers and a yellow flyer bulging out of the top slot
October 6, 2026
Google Stops Accepting Product Bug Reports for Its Open-Source Bounty, Citing Automated Submissions
Chronophotograph by Étienne-Jules Marey of a man riding a bicycle, showing five snapshots of the same ride taken at regular intervals
October 6, 2026
How to Find Slow Python Code With the Python 3.15 Tachyon Sampling Profiler
Close-up of an airport baggage tag reading Stockholm Arlanda and ARN
October 6, 2026
Cloudflare Traces Turns Distributed Tracing Into a Trust Decision at the Edge
06 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Shelves of old books fastened by iron chains in the Francis Trigge Chained Library in Grantham, England, a picture of data that can be read but not changed
How to Use frozendict in Python 3.15 to Freeze Config and Cache Dictionary Arguments
October 5, 2026
Row of capsule hotel pods with white pillows and folded blankets, each capsule an idle sleeper packed into a shared rack
Kubernetes Node Swap Turns Idle Agent Memory Into a Density Bet With No Wake-Up Test
October 5, 2026
Denmark’s oldest church book, from Holmens parish, open on a stack of books; its handwritten pages record births between 1617 and 1639
Denmark Says 8.8 Million Population Register Records Were Pulled Through One Company’s Lawful Access
October 5, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 226 Posts
News 228 Posts
Learning Hub 198 Posts
Home/Learning Hub/How to Cut a Python Command Line Tool’s Startup Time With Python 3.15 Lazy Imports
Learning Hub

How to Cut a Python Command Line Tool’s Startup Time With Python 3.15 Lazy Imports

Measure a Python command line tool’s import cost, mark the slow imports lazy with the new Python 3.15 keyword, and learn six gotchas, using numbers measured on Python 3.15.0rc3.

October 4, 2026 23 Min Read
19

Every time you run a Python command line tool, even just to print its version number, Python first loads every library that the script imports at the top of its file. If the tool has five commands, each run pays for the libraries of all five, although it uses one. For a tool that otherwise finishes in a few milliseconds, that loading is most of the wait.

Table Of Content

  • The ideas in plain language
  • What an import costs
  • What a lazy import is
  • Prerequisites
  • Step 1: Create the sample command line tool
  • The sample data
  • The tool, with every import at the top
  • Check that it works
  • Step 2: Measure before you change anything
  • Find the slow imports with -X importtime
  • Time whole runs with a small benchmark script
  • Step 3: Try the zero-edit switch to see what is possible
  • Step 4: Mark the slow imports lazy
  • Copy the file and change the import block
  • Check that nothing breaks
  • Check that nothing loads at startup
  • See what is left
  • Step 5: Measure again and watch where the cost went
  • The numbers
  • Watch imports happen on first use
  • Step 6: Learn the gotchas before they bite
  • Gotcha 1: Errors move to the first use
  • Gotcha 2: Where the lazy keyword is allowed
  • Gotcha 3: Import-time side effects move too
  • Gotcha 4: Security tools that watch imports see them later
  • Gotcha 5: You cannot peek at a lazy name without loading it
  • Gotcha 6: The same file on an older Python
  • A safer global switch: the filter
  • Step 7: Guard the startup time with tests
  • Check the whole thing end to end
  • Common mistakes
  • Next steps

In this tutorial you will measure that cost on a small inventory tool, and then use the lazy keyword that Python 3.15 adds for imports (PEP 810) to postpone loading a library until a command actually needs it. On my machine the tool’s --version run fell from 128.7 ms to 62.2 ms. You will also see where the postponed cost goes, what happens when you flip the global switch instead of editing code, and six ways the feature can surprise you. You do not need to know anything about Python’s internals. Every command below was run for this article, and every output block is pasted from a real run.

A note about versions. When I wrote this, Python 3.15 was in its release candidate phase. PEP 790, the release schedule, lists 3.15.0 candidate 3 for Friday, 2026-10-02 and expects the final release on Friday, 2026-10-09. I tested on 3.15.0rc3 on Windows 11, on a machine with an Intel Core Ultra 5 235. If 3.15.0 final is out when you read this, use it. Where I quote the Python documentation, I am quoting the 3.15 pages; where the documentation is silent, I tell you what I observed on rc3.

The ideas in plain language

What an import costs

When Python runs import json it does real work. The What’s New in Python 3.15 page describes it this way: “Python must locate the file, read it from disk, compile it to bytecode, and execute all top-level code.” Top-level code means the statements at the left margin of a module, and that includes the module’s own imports. So one import can set off a chain. You can count it:

python -c "import sys; before = set(sys.modules); import asyncio; print(len(set(sys.modules) - before), 'modules added by import asyncio')"
116 modules added by import asyncio

On my machine that single import asyncio line made Python load 116 modules before the next line of the program could run.

In this article, startup time means the wall-clock time from launching the Python process until your own code can start doing its job. A command line tool is the worst case for this cost, because it starts, does a little work and exits, over and over.

What a lazy import is

An ordinary import is eager: it runs at the line where it appears. A lazy import is a promise to import later. Per the language reference, “a lazy proxy object is created and bound to the name”, and “The actual module is loaded on first use of that name.” A proxy is a stand-in object. The PEP uses the word reification for the moment the real module replaces the stand-in, and I will use the plainer phrase “first use”.

lazy is a soft keyword: the reference says it “only has special meaning when it appears immediately before an import or from statement”, so existing variables called lazy keep working. The PEP’s abstract names the sweet spot: “This is particularly beneficial for command-line tools, test suites, and applications with large dependency graphs.” And its FAQ gives the rule to remember through the whole tutorial: “lazy imports only change when something happens, not what happens.”

Prerequisites

  • Python 3.15 (3.15.0rc3 or later). Check with python --version. On macOS and Linux the command may be python3.15; wherever this article says python, use the command that starts 3.15 on your machine.
  • A terminal and a text editor. PowerShell, Command Prompt, bash and zsh all work.
  • Basic Python. You should be comfortable with functions, the import statement and running a script. I explain everything else as we go.
  • About 45 minutes and an empty folder, for example invtool. Every file in this tutorial goes into that one folder. Everything uses the standard library, except pytest in the last step (python -m pip install pytest).

Confirm that your interpreter is the right one:

python -VV
Python 3.15.0rc3 (tags/v3.15.0rc3:8a8eb0b, Oct  2 2026, 19:16:27) [MSC v.1951 64 bit (AMD64)]

The exact build details will differ on your machine. What matters is that it says 3.15. Use this same interpreter for every command below. The two helper scripts you will write launch child runs with sys.executable, which is the interpreter that is running them, so they measure the same Python you start them with.

Step 1: Create the sample command line tool

The sample data

Save this as inventory.csv. It is a tiny stock list: a product code, how many units are on the shelf and the price of one unit.

sku,qty,unit_price
A-100,12,4.50
A-101,0,12.00
B-200,48,0.75
B-201,7,19.99
C-300,3,249.00
C-301,150,1.25
D-400,22,8.40

The tool, with every import at the top

Save this as invtool_eager.py. It is deliberately ordinary: all the imports sit at the top, in the conventional layout, and five subcommands do different jobs. summary totals a CSV file, export prints it as JSON, store copies it into SQLite, fetch downloads a URL and watch runs a tiny asyncio loop.

# invtool_eager.py
"""invtool: a small inventory command line tool. Every import is at the top, the usual way."""
import argparse
import asyncio
import csv
import json
import sqlite3
import statistics
import urllib.request
from decimal import Decimal

VERSION = "1.0"


def load_rows(path):
    with open(path, newline="", encoding="utf-8") as handle:
        return list(csv.DictReader(handle))


def cmd_summary(args):
    rows = load_rows(args.csv)
    prices = [Decimal(row["unit_price"]) for row in rows]
    value = sum(Decimal(row["qty"]) * Decimal(row["unit_price"]) for row in rows)
    print(f"items: {len(rows)}")
    print(f"median price: {statistics.median(prices)}")
    print(f"stock value: {value}")


def cmd_export(args):
    print(json.dumps(load_rows(args.csv), indent=2))


def cmd_store(args):
    rows = load_rows(args.csv)
    with sqlite3.connect(args.db) as conn:
        conn.execute("CREATE TABLE IF NOT EXISTS items (sku TEXT, qty INTEGER, unit_price TEXT)")
        conn.executemany("INSERT INTO items VALUES (:sku, :qty, :unit_price)", rows)
    print(f"stored {len(rows)} rows in {args.db}")


def cmd_fetch(args):
    with urllib.request.urlopen(args.url, timeout=10) as response:
        print(len(response.read()), "bytes")


async def count_ticks(ticks):
    for _ in range(ticks):
        await asyncio.sleep(0)
    return ticks


def cmd_watch(args):
    print("ticks:", asyncio.run(count_ticks(args.ticks)))


def build_parser():
    parser = argparse.ArgumentParser(prog="invtool")
    parser.add_argument("--version", action="version", version=f"invtool {VERSION}")
    commands = parser.add_subparsers(dest="command", required=True)

    summary = commands.add_parser("summary", help="print totals for a CSV file")
    summary.add_argument("csv")
    summary.set_defaults(func=cmd_summary)

    export = commands.add_parser("export", help="print a CSV file as JSON")
    export.add_argument("csv")
    export.set_defaults(func=cmd_export)

    store = commands.add_parser("store", help="copy a CSV file into SQLite")
    store.add_argument("csv")
    store.add_argument("db")
    store.set_defaults(func=cmd_store)

    fetch = commands.add_parser("fetch", help="download a URL and count its bytes")
    fetch.add_argument("url")
    fetch.set_defaults(func=cmd_fetch)

    watch = commands.add_parser("watch", help="run a tiny asyncio loop")
    watch.add_argument("--ticks", type=int, default=100)
    watch.set_defaults(func=cmd_watch)
    return parser


def main(argv=None):
    args = build_parser().parse_args(argv)
    args.func(args)


if __name__ == "__main__":
    main()

Notice how few of those imports a single run needs. summary uses csv, statistics and decimal and nothing else. --version uses none of them. Yet the interpreter executes every import line before it even looks at the command line, and that is the cost we are about to measure.

Check that it works

python invtool_eager.py --version
python invtool_eager.py summary inventory.csv
invtool 1.0
items: 7
median price: 8.40
stock value: 1349.23

The summary says there are seven items, the median unit price is 8.40 and the stock is worth 1349.23. I checked the last number by hand: 12 × 4.50 plus 0 plus 48 × 0.75 plus 7 × 19.99 plus 3 × 249.00 plus 150 × 1.25 plus 22 × 8.40 is 1349.23. The tool uses Decimal so that money does not pick up floating-point noise. If you see these numbers, Step 1 worked.

Step 2: Measure before you change anything

Performance work without measurements is guessing. You need two kinds of numbers: which imports are expensive, and how long a whole run takes as a user experiences it.

Find the slow imports with -X importtime

Python can time its own imports. The command line documentation says -X importtime “shows module name, cumulative time (including nested imports) and self time (excluding nested imports).” The -X flag passes an implementation option to the interpreter. Here is the raw output for a single import of json, with times in microseconds:

python -X importtime -c "import json"
import time:       527 |       6324 |     re
import time:        45 |         45 |       _json
import time:       426 |        471 |     json.scanner
import time:       519 |       7312 |   json.decoder
import time:       412 |        412 |   json.encoder
import time:       464 |       8187 | json

Read each line as: time spent in this module’s own code, time including everything it imported, and the module name, indented to show who imported whom. Importing json took about 8 ms here, and roughly 6 ms of that is re, the regular expression module, which sits one level deeper because json.decoder imported it.

That detail matters later: shared dependencies are billed to whoever imports them first. In our tool an earlier import has already loaded re by the time json is imported, so json will show up as cheap in the next table. Raw output for a whole program is hundreds of lines, so this small script ranks it for you. Save it as top_imports.py. It runs your script twice under -X importtime: once as a bare interpreter, once as your script. It drops every module the bare interpreter loads anyway, keeps only the imports your script triggers directly and sorts them by cumulative time.

# top_imports.py
"""Rank the imports a script adds on top of a bare interpreter by cumulative import time."""
import re
import subprocess
import sys

LINE = re.compile(r"^import time:\s+(\d+) \|\s+(\d+) \|(\s*)(\S+)$")


def import_times(args):
    result = subprocess.run(
        [sys.executable, "-X", "importtime", *args],
        capture_output=True, text=True, encoding="utf-8", errors="replace",
    )
    rows = []
    for line in result.stderr.splitlines():
        match = LINE.match(line)
        if match:
            self_us, cumulative_us, indent, name = match.groups()
            rows.append((name, int(self_us) / 1000, int(cumulative_us) / 1000, len(indent)))
    return rows


def main():
    baseline = {name for name, *_ in import_times(["-c", "pass"])}
    rows = [row for row in import_times(sys.argv[1:]) if row[0] not in baseline]
    if not rows:
        print("no imports beyond the bare interpreter")
        return
    top_level = min(depth for *_, depth in rows)
    own = sorted((row for row in rows if row[3] == top_level), key=lambda row: -row[2])
    print(f"{'module':<24}{'cumulative ms':>14}")
    for name, _self_ms, cumulative_ms, _depth in own[:8]:
        print(f"{name:<24}{cumulative_ms:>14.1f}")
    print(f"{'all of them together':<24}{sum(row[2] for row in own):>14.1f}")


main()
python top_imports.py invtool_eager.py --version
module                   cumulative ms
asyncio                           46.2
urllib.request                    18.4
argparse                           7.6
_colorize                          7.3
statistics                         3.9
sqlite3                            2.3
json                               1.6
csv                                0.4
all of them together              87.6

Two imports dominate: asyncio at 46.2 ms and urllib.request at 18.4 ms. Together the imports add up to 87.6 ms. argparse and _colorize load on every run, so they are not candidates. Only the eight most expensive rows are printed, which is why the other imports do not appear. These are single measurements, so treat them as a ranking, not as exact costs.

Time whole runs with a small benchmark script

Import times are one view. What a user feels is the total time for the process to start, run and exit, so we measure that too. Save this as bench.py:

# bench.py
"""Time whole-process runs (interpreter start, imports and the command) for named scenarios."""
import statistics
import subprocess
import sys
import time

SCENARIOS = {
    "floor": ["-c", "pass"],
    "eager-version": ["invtool_eager.py", "--version"],
    "eager-all": ["-X", "lazy_imports=all", "invtool_eager.py", "--version"],
    "lazy-version": ["invtool_lazy.py", "--version"],
    "eager-summary": ["invtool_eager.py", "summary", "inventory.csv"],
    "lazy-summary": ["invtool_lazy.py", "summary", "inventory.csv"],
    "eager-watch": ["invtool_eager.py", "watch", "--ticks", "100"],
    "lazy-watch": ["invtool_lazy.py", "watch", "--ticks", "100"],
}
ROUNDS = 40


def run_once(args):
    start = time.perf_counter()
    subprocess.run([sys.executable, *args], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
    return (time.perf_counter() - start) * 1000


def main(names):
    for name in names:
        run_once(SCENARIOS[name])  # warm-up: let Python write its .pyc files and warm the disk cache
    samples = {name: [] for name in names}
    for _ in range(ROUNDS):
        for name in names:  # interleave scenarios so background noise hits all of them alike
            samples[name].append(run_once(SCENARIOS[name]))
    for name in names:
        times = sorted(samples[name])
        print(f"{name:<14} median {statistics.median(times):6.1f} ms   fastest {times[0]:6.1f} ms")


main(sys.argv[1:])

Four details make the numbers trustworthy. First, one throwaway run per scenario happens before timing, because the first run after an install can be slower while Python writes its bytecode caches. Second, the scenarios take turns for 40 rounds, so a burst of background activity on your machine lands on all of them alike. Third, it reports the median, the middle value, which one slow outlier cannot drag around the way it drags an average. Fourth, the floor scenario runs python -c pass: the cost of merely starting the interpreter on this machine. Anything above the floor is the price of our own code and imports.

python bench.py floor eager-version
floor          median   31.8 ms   fastest   29.8 ms
eager-version  median  127.1 ms   fastest  124.9 ms

Starting Python costs about 32 ms here, and the eager tool needs about 127 ms just to print its version. Roughly 95 ms of that is ours, and about 88 ms of it was imports according to the previous table. That is the target.

Step 3: Try the zero-edit switch to see what is possible

Before editing any code, there is a quick experiment. The documentation says -X lazy_imports= accepts all or normal, where “all makes all imports lazy by default, and normal (the default) respects the lazy keyword in source code.” The environment variable PYTHON_LAZY_IMPORTS does the same job. Run the benchmark with a third scenario that adds the flag to the unchanged eager file:

python bench.py floor eager-version eager-all
floor          median   31.9 ms   fastest   29.4 ms
eager-version  median  127.3 ms   fastest  124.6 ms
eager-all      median   59.5 ms   fastest   56.5 ms

With no code change at all, the version run drops from about 127 ms to about 60 ms. To see why, save this small script as prove.py. It imports a module and then reports which of seven heavy modules are already loaded, by asking sys.modules, the table of every module Python has loaded so far.

# prove.py
"""Report which heavy modules are already loaded right after importing a module."""
import importlib
import sys

HEAVY = ["asyncio", "csv", "decimal", "json", "sqlite3", "statistics", "urllib.request"]

name = sys.argv[1]
importlib.import_module(name)
loaded = [module for module in HEAVY if module in sys.modules]
print(f"after import {name}: {len(loaded)} of {len(HEAVY)} heavy modules loaded")
print("  loaded:", loaded or "none")
python prove.py invtool_eager
after import invtool_eager: 7 of 7 heavy modules loaded
  loaded: ['asyncio', 'csv', 'decimal', 'json', 'sqlite3', 'statistics', 'urllib.request']

Now the same command with the environment variable set. The first line is for bash and zsh, the second for PowerShell:

PYTHON_LAZY_IMPORTS=all python prove.py invtool_eager

$env:PYTHON_LAZY_IMPORTS = "all"; python prove.py invtool_eager; Remove-Item Env:PYTHON_LAZY_IMPORTS
after import invtool_eager: 0 of 7 heavy modules loaded
  loaded: none

Same file, but nothing was loaded at import time. Treat this as a diagnostic, not a shipping strategy. The switch changes every import in the whole process, including imports inside libraries you did not write and cannot test. Step 6 shows a program with no lazy keyword at all whose behavior changes under it. The safer route is to mark the specific imports you have checked.

Step 4: Mark the slow imports lazy

Copy the file and change the import block

Make a copy so that you can compare the two versions side by side:

# macOS, Linux, Git Bash
cp invtool_eager.py invtool_lazy.py

# PowerShell or Command Prompt
copy invtool_eager.py invtool_lazy.py

In invtool_lazy.py, replace the eight lines of imports near the top (everything from import argparse through from decimal import Decimal) with the block below. Nothing else in the file changes.

# invtool_lazy.py (new import block, replaces the eight import lines in the copy)
import argparse
lazy import asyncio
lazy import csv
lazy import json
lazy import sqlite3
lazy import statistics
lazy import urllib.request
lazy from decimal import Decimal

Here is what each choice means. lazy import asyncio binds the name asyncio to a stand-in and does not load anything. lazy import urllib.request keeps the dotted name usable, so the code that says urllib.request.urlopen still works; Step 7 proves it. lazy from decimal import Decimal is the from form: the reference says that “each imported name is bound to a lazy proxy object” and that “The first access to any of these names triggers loading of the entire module and resolves only that specific name to its actual value.” argparse stays a normal import on purpose, because every run builds a parser immediately, so making it lazy could not save anything.

Check that nothing breaks

python invtool_lazy.py summary inventory.csv
items: 7
median price: 8.40
stock value: 1349.23

The output is identical to the eager version, which is what only when, not what promises. If you get a SyntaxError here, you are not running Python 3.15; see Gotcha 6.

Check that nothing loads at startup

python prove.py invtool_lazy
after import invtool_lazy: 0 of 7 heavy modules loaded
  loaded: none

Earlier, the eager file loaded seven of seven heavy modules at import. The lazy file loads none. That is the whole feature in one line of output.

See what is left

python top_imports.py invtool_lazy.py --version
module                   cumulative ms
_colorize                          9.0
argparse                           8.7
shutil                             7.1
textwrap                           0.7
locale                             0.6
all of them together              26.1

The two big costs are gone. None of the modules still listed is one we marked lazy: argparse is our only remaining eager import, so these are what it loads while it builds and runs the parser. Every run needs the parser, so lazy imports cannot help here. This is a useful rule of thumb: lazy imports only save work that some runs can skip.

Step 5: Measure again and watch where the cost went

The numbers

Now compare eager and lazy on three different commands: the version flag, summary, and watch, which really does need asyncio.

python bench.py floor eager-version lazy-version eager-summary lazy-summary eager-watch lazy-watch
floor          median   32.9 ms   fastest   29.8 ms
eager-version  median  128.7 ms   fastest  126.1 ms
lazy-version   median   62.2 ms   fastest   59.0 ms
eager-summary  median  122.9 ms   fastest  119.8 ms
lazy-summary   median   56.6 ms   fastest   53.8 ms
eager-watch    median  124.7 ms   fastest  120.5 ms
lazy-watch     median  101.2 ms   fastest   98.0 ms
Command Eager Lazy Difference
--version 128.7 ms 62.2 ms 66.5 ms less (52 percent)
summary inventory.csv 122.9 ms 56.6 ms 66.3 ms less (54 percent)
watch --ticks 100 124.7 ms 101.2 ms 23.5 ms less (19 percent)

The percentages are my arithmetic on the medians above. The first two rows roughly halve the run. The third row is the lesson: watch needs asyncio, so it pays that import on first use, and the saving shrinks to the other six libraries it skipped. Lazy imports move cost to the place that needs it. They delete it only for runs that never need it. Also note that the floor drifted from 31.8 ms to 31.9 ms to 32.9 ms across my three benchmark runs. A millisecond of drift between runs is normal, so compare rows within one block of output, not across blocks. I did not investigate why summary is a few milliseconds faster than --version; both are far below the eager rows.

Watch imports happen on first use

This script imports the lazy tool as a module and checks sys.modules after each step, so you can watch the stand-ins turn into real modules.

# first_use.py
"""Watch lazy imports turn into real modules, one first use at a time."""
import sys

import invtool_lazy as tool

WATCH = ["csv", "decimal", "statistics", "sqlite3"]


def loaded():
    return [name for name in WATCH if name in sys.modules]


print("1. right after import:       ", loaded())
tool.load_rows("inventory.csv")
print("2. after load_rows():        ", loaded())
tool.main(["summary", "inventory.csv"])
print("3. after the summary command:", loaded())
tool.main(["store", "inventory.csv", ":memory:"])
print("4. after the store command:  ", loaded())
python first_use.py
1. right after import:        []
2. after load_rows():         ['csv']
items: 7
median price: 8.40
stock value: 1349.23
3. after the summary command: ['csv', 'decimal', 'statistics']
stored 7 rows in :memory:
4. after the store command:   ['csv', 'decimal', 'statistics', 'sqlite3']

Read it top to bottom. Right after the import, none of the four modules is loaded. Reading the CSV file loads csv. Running summary adds decimal and statistics. Only the store command brings in sqlite3. Each library arrived at the moment something needed it, and not before.

Step 6: Learn the gotchas before they bite

Lazy imports are simple to write and easy to misuse. Each gotcha below has a short script, so you can see the behavior yourself.

Gotcha 1: Errors move to the first use

With an eager import, a missing module stops the program at the top. With a lazy import, nothing fails until the name is used. The reference says so: the error is “raised at the point where the lazy import is first used, not at the import statement itself.” Save this as error_timing.py; acme_missing_dep does not exist.

# error_timing.py
lazy import acme_missing_dep

print("startup finished, nothing has failed yet")


def run():
    return acme_missing_dep.do_work()


run()
python error_timing.py
startup finished, nothing has failed yet
Traceback (most recent call last):
  File "C:\invtool\error_timing.py", line 2, in <module>
    lazy import acme_missing_dep
ImportError: lazy import of 'acme_missing_dep' raised an exception during resolution

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "C:\invtool\error_timing.py", line 11, in <module>
    run()
    ~~~^^
  File "C:\invtool\error_timing.py", line 8, in run
    return acme_missing_dep.do_work()
           ^^^^^^^^^^^^^^^^
ModuleNotFoundError: No module named 'acme_missing_dep'

The program printed its startup finished line, because the failing import had not run yet. The traceback has two parts. The first points at the lazy import line and says the import raised an exception during resolution. After the line The above exception was the direct cause of the following exception, the second part shows where the name was actually used, ending in the real ModuleNotFoundError. That chain is handy for debugging, but the practical consequence is a change of timing: a typo in a module name or a missing optional dependency now surfaces in the middle of a run, possibly in a rarely used command. Step 7 shows how to catch that in tests.

Gotcha 2: Where the lazy keyword is allowed

The language reference says lazy imports “are only permitted at module scope” and that using lazy inside a function, a class body or a try, except or finally block “raises a SyntaxError.” Star imports and future imports cannot be lazy either. Save this as syntax_rules.py. It compiles five bad snippets and prints what the compiler says:

# syntax_rules.py
"""Compile five snippets that misuse the lazy keyword and print what the compiler says."""
SNIPPETS = {
    "inside a function": "def f():\n    lazy import json\n",
    "inside a class": "class C:\n    lazy import json\n",
    "inside try/except": "try:\n    lazy import orjson\nexcept ImportError:\n    orjson = None\n",
    "star import": "lazy from json import *\n",
    "future import": "lazy from __future__ import annotations\n",
}

for label, source in SNIPPETS.items():
    try:
        compile(source, "<snippet>", "exec")
        print(f"{label:<18} compiled")
    except SyntaxError as error:
        print(f"{label:<18} SyntaxError: {error.msg}")
python syntax_rules.py
inside a function  SyntaxError: lazy import not allowed inside functions
inside a class     SyntaxError: lazy import not allowed inside classes
inside try/except  SyntaxError: lazy import not allowed inside try/except blocks
star import        SyntaxError: lazy from ... import * is not allowed
future import      SyntaxError: lazy from __future__ import is not allowed

The try and except line is the one that hurts, because wrapping an optional import in try and except ImportError is a classic Python pattern. A lazy-friendly replacement is to ask the import system whether the module exists, which needs no try, and then choose which module to bind. Save this as optional_dep.py:

# optional_dep.py
"""Use a faster JSON library when it is installed, without a try/except around a lazy import."""
from importlib.util import find_spec

if find_spec("orjson") is not None:
    lazy import orjson as fast_json
else:
    lazy import json as fast_json

print("startup finished; library chosen:", "orjson" if find_spec("orjson") else "json")
print(fast_json.dumps({"sku": "A-100"}))
python optional_dep.py
startup finished; library chosen: json
{"sku": "A-100"}

An if block at the top level of a file is still module scope, so both branches may use lazy. find_spec returns None when the module cannot be found, so the script falls back to the standard library json. Both branches bind the same name, fast_json, so the rest of the program does not care which one won.

Gotcha 3: Import-time side effects move too

Some modules do work when they are imported, such as registering themselves in a table. The PEP’s FAQ is blunt about this: “Side effects are deferred until first use.” It recommends that you “Use explicit initialization functions instead of import-time side effects”, which is sound advice with or without lazy imports. Here is a miniature plugin system. registry.py holds a table and fmt_shout.py registers a format when it is imported:

# registry.py
FORMATS = {}


def register(name):
    def decorator(func):
        FORMATS[name] = func
        return func
    return decorator
# fmt_shout.py
from registry import register


@register("shout")
def render(text):
    return text.upper() + "!"

First the eager version, then a lazy version that also prints the table after the module has been used:

# plugins_eager.py
import registry
import fmt_shout

print("registered formats:", sorted(registry.FORMATS))
# plugins_lazy.py
import registry
lazy import fmt_shout

print("before first use:", sorted(registry.FORMATS))
fmt_shout.render("hi")
print("after first use: ", sorted(registry.FORMATS))
python plugins_eager.py
python plugins_lazy.py
registered formats: ['shout']
before first use: []
after first use:  ['shout']

With the lazy import, the table is empty until something touches fmt_shout. A program that lists its available formats at startup would silently show none. Now the surprising part. Run the eager file, which contains no lazy keyword at all, with the global switch from Step 3:

python -X lazy_imports=all plugins_eager.py
registered formats: []

The program lost its registered format without a single edit. This is why I called the global switch a diagnostic. A module that is imported for its side effects should stay a normal import, or be given an explicit initialization function.

Gotcha 4: Security tools that watch imports see them later

Python’s audit hooks let a program observe events such as imports, and some supply chain defenses use them to watch what a package does while it loads; my audit hooks tutorial builds one. Lazy imports postpone the very moment such a tool is watching. This script registers a hook for the import event before a lazy import of json:

# audit_demo.py
"""A tool that watches imports through audit hooks sees a lazy import at its first use, not at the statement."""
import sys

seen = []


def hook(event, args):
    if event == "import" and args[0] == "json":
        seen.append(args[0])


sys.addaudithook(hook)
lazy import json

print("after the import statement:", seen)
json.dumps({})
print("after the first use:       ", seen)
python audit_demo.py
after the import statement: []
after the first use:        ['json']

The hook saw nothing at the import statement and saw json at the first use. For comparison, I removed the word lazy from that one line and ran it again:

after the import statement: ['json']
after the first use:        ['json']

The event is still raised, so the hook still sees this import, but it now arrives at the first use, which can be much later in the run. If you guard a startup phase this way, make sure it keeps running for the life of the program, or leave security-relevant imports eager.

Gotcha 5: You cannot peek at a lazy name without loading it

The What’s New page says that “The proxy type itself is available as types.LazyImportType for code that needs to detect lazy imports programmatically.” I tried to catch a proxy by reading the module namespace in five ways. Each attempt runs in a fresh interpreter so that one cannot spoil the next:

# namespace_probe.py
"""Does reading a lazy name out of the namespace dictionary leave it lazy? Try each way in a fresh interpreter."""
import subprocess
import sys

WAYS = [
    'globals()["json"]',
    'vars()["json"]',
    'globals().get("json")',
    'dict(globals())["json"]',
    'list(globals().values())[-1]',
]
TEMPLATE = """
import sys, types
lazy import json
value = {way}
print(type(value).__name__, "| lazy proxy:", isinstance(value, types.LazyImportType), "| json loaded:", "json" in sys.modules)
"""

for way in WAYS:
    result = subprocess.run([sys.executable, "-c", TEMPLATE.format(way=way)], capture_output=True, text=True)
    print(f"{way:<30}", (result.stdout or result.stderr).strip())
python namespace_probe.py
globals()["json"]              module | lazy proxy: False | json loaded: True
vars()["json"]                 module | lazy proxy: False | json loaded: True
globals().get("json")          module | lazy proxy: False | json loaded: True
dict(globals())["json"]        module | lazy proxy: False | json loaded: True
list(globals().values())[-1]   module | lazy proxy: False | json loaded: True

On 3.15.0rc3 every one of these reads imported the module and handed back the real thing, never a proxy. The documentation does not promise this either way, so do not build tools that depend on it. The reliable way to ask whether a module has loaded is the one prove.py and first_use.py use: check sys.modules.

Gotcha 6: The same file on an older Python

The lazy keyword is new syntax, so an older interpreter cannot even parse the file. This is what Python 3.13 says about invtool_lazy.py:

  File "C:\invtool\invtool_lazy.py", line 4
    lazy import asyncio
         ^^^^^^
SyntaxError: invalid syntax

If your tool must keep running on older versions, the documentation offers a compatible spelling. A module can define __lazy_modules__, which “must be a container of fully qualified module name strings”, and then “Any regular (non-lazy) import statement at module scope whose target appears in __lazy_modules__ is treated as a lazy import, exactly as if the lazy keyword had been used.” Older interpreters simply ignore the variable:

# compat.py
"""One file, two behaviors: Python 3.15 defers these imports, older Pythons import them right away."""
__lazy_modules__ = ["json", "sqlite3"]

import json
import sqlite3
import sys

print(
    f"Python {sys.version_info.major}.{sys.version_info.minor}:",
    "json loaded:", "json" in sys.modules,
    "| sqlite3 loaded:", "sqlite3" in sys.modules,
)
python compat.py

Here are the results on 3.15 and on Python 3.13 running the identical file:

Python 3.15: json loaded: False | sqlite3 loaded: False
Python 3.13: json loaded: True | sqlite3 loaded: True

One file, two behaviors: lazy where the interpreter supports it, eager everywhere else. That makes it the gentlest way to adopt the feature in a library that still supports older versions.

A safer global switch: the filter

If you like the idea of a global switch but want control, Python 3.15 lets you install a filter. The sys documentation says the function is called for every potentially lazy import, and that “The filter should return True to allow the import to be lazy, or False to force an eager import.” It also warns that this is “an advanced feature intended for specialized users who need fine-grained control over lazy import behavior.” The filter gets the importing module, the imported module and the names in a from import. This script makes only sqlite3 and asyncio eligible:

# filter_demo.py
"""Make every import lazy by default, but let a filter decide which modules may actually be lazy."""
import sys


def only_the_slow_ones(importing, imported, fromlist):
    return imported in {"sqlite3", "asyncio"}


sys.set_lazy_imports_filter(only_the_slow_ones)
sys.set_lazy_imports("all")

import json
import sqlite3

print("lazy mode:", sys.get_lazy_imports())
print("json loaded:   ", "json" in sys.modules)
print("sqlite3 loaded:", "sqlite3" in sys.modules)
python filter_demo.py
lazy mode: all
json loaded:    True
sqlite3 loaded: False

sys.set_lazy_imports("all") turned the global mode on, but the filter said no to json, so it loaded normally, while sqlite3 stayed lazy. Use sys.get_lazy_imports() to read the current mode.

Step 7: Guard the startup time with tests

Performance gains rot quietly. Someone adds one top-level import six months from now and the tool is slow again. A test can notice. The tests below check which modules are loaded instead of how many milliseconds a run took, because millisecond thresholds are flaky on shared machines while module membership is exact. Save this as test_startup.py:

# test_startup.py
"""Guard rails for startup cost: heavy modules must not load until a command needs them."""
import re
import sqlite3
import subprocess
import sys
from pathlib import Path

LAB = Path(__file__).parent
HEAVY = ["asyncio", "sqlite3", "urllib.request"]
IMPORT_LINE = re.compile(r"^import time:\s+\d+ \|\s+\d+ \|\s*(\S+)$")


def run(*args):
    return subprocess.run(
        [sys.executable, *args], cwd=LAB, capture_output=True, text=True, encoding="utf-8"
    )


def heavy_loaded_after_import(module):
    code = f"import sys, {module}; print(','.join(m for m in {HEAVY!r} if m in sys.modules))"
    result = run("-c", code)
    assert result.returncode == 0, result.stderr
    return set(filter(None, result.stdout.strip().split(",")))


def imported_modules(*args):
    result = run("-X", "importtime", *args)
    assert result.returncode == 0, result.stderr
    lines = (IMPORT_LINE.match(line) for line in result.stderr.splitlines())
    return {match.group(1) for match in lines if match}


def test_eager_version_loads_every_heavy_module():
    assert heavy_loaded_after_import("invtool_eager") == set(HEAVY)


def test_lazy_version_loads_no_heavy_module():
    assert heavy_loaded_after_import("invtool_lazy") == set()


def test_version_flag_imports_no_heavy_module():
    assert not set(HEAVY) & imported_modules("invtool_lazy.py", "--version")


def test_summary_output_is_unchanged():
    eager = run("invtool_eager.py", "summary", "inventory.csv")
    lazy = run("invtool_lazy.py", "summary", "inventory.csv")
    assert lazy.returncode == 0
    assert lazy.stdout == eager.stdout


def test_store_loads_sqlite_on_demand(tmp_path):
    db = tmp_path / "items.db"
    assert "sqlite3" in imported_modules("invtool_lazy.py", "store", "inventory.csv", str(db))
    conn = sqlite3.connect(db)
    try:
        assert conn.execute("SELECT COUNT(*) FROM items").fetchone() == (7,)
    finally:
        conn.close()


def test_watch_loads_asyncio_on_demand():
    assert "asyncio" in imported_modules("invtool_lazy.py", "watch", "--ticks", "5")


def test_fetch_loads_urllib_on_demand():
    url = (LAB / "inventory.csv").as_uri()
    assert "urllib.request" in imported_modules("invtool_lazy.py", "fetch", url)


def test_missing_dependency_fails_at_first_use():
    result = run("error_timing.py")
    assert result.returncode != 0
    assert "startup finished" in result.stdout
    assert "ModuleNotFoundError" in result.stderr

The first test documents the premise: the eager file loads all three heavy modules, so if a future Python release changed that, you would find out here. The second and third prove the lazy file keeps them out of startup. The fourth shows the output of summary is unchanged, and the fifth through seventh prove that store, watch and fetch still load what they need, including the dotted urllib.request case from Step 4. The last test covers Gotcha 1. Run it with the same interpreter:

python -m pip install pytest
python -m pytest -q test_startup.py
........                                                                                     [100%]
8 passed in 0.81s

Eight passed. If a later edit makes invtool_lazy.py import asyncio at the top, the second test fails and names the module, which is exactly the early warning you want in continuous integration.

Check the whole thing end to end

Before you apply this to your own tool, confirm all of these:

  1. python -VV reports 3.15.
  2. python prove.py invtool_eager reports seven of seven heavy modules loaded, and python prove.py invtool_lazy reports none.
  3. python bench.py floor eager-version lazy-version shows the lazy row at roughly half the eager row, with the floor far below both.
  4. python first_use.py shows modules appearing one at a time.
  5. python -m pytest -q test_startup.py passes.

Common mistakes

  • Making argparse lazy and expecting a win. In this tool every run builds a parser at once, so the import loads immediately and nothing is saved.
  • Putting lazy inside try, inside a function or in a class body. All three are a SyntaxError. Check availability with find_spec at module scope instead.
  • Marking a plugin or registration module lazy. It will not register until something touches it.
  • Trusting a single timing run. One run includes cache warm-up and noise. Use many interleaved runs and compare medians within one block.
  • Comparing numbers across different benchmark runs. The floor drifted by a millisecond between mine.
  • Shipping -X lazy_imports=all without testing. It changed an eager program’s behavior in Gotcha 3.
  • Forgetting older Pythons. Use __lazy_modules__ if the file must still parse on them.

Next steps

  • Try it on your own tool. Run top_imports.py on its entry point, mark the two or three most expensive imports that some commands skip, and compare medians with bench.py.
  • Add a startup guard to your tests like Step 7, listing the modules that must stay out of the startup path.
  • Read the specification. The PEP is marked as a historical document, with the canonical text in the language reference; its FAQ answers questions about circular imports, threads and import hooks that this tutorial did not touch.
  • Measure inside your program too. My tutorial on building a timer you can use as a context manager or decorator pairs well with this one.
  • Plan your upgrade. My earlier piece on the Python 3.15 feature freeze explained why the beta period was a practical window to test code and extensions, and it singles out projects that “rely on import-time customization”, the situation Gotcha 3 describes. The final release is expected on 2026-10-09, so rerun this lab on it when it lands.

Tags:

Command LineDeveloper ToolingPerformance EngineeringPythonPython 3.15

Share

Four white chalk strokes crossed by a diagonal fifth stroke on a black chalkboard, a tally of five
Previous Post

Microsoft’s ThinkingBox Turns AI Agent Scores Into a Database Audit, Repeated 20 Times

North facade of the White House in Washington, D.C., with the fountain and red flower beds in the foreground
Next Post

Trump Launches a “Super Intelligence Force” Under Jay Clayton, Five Days After a Voluntary AI Accord

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
05 Oct
How to Use frozendict in Python 3.15 to Freeze Config and Cache Dictionary Arguments
05 Oct
Kubernetes Node Swap Turns Idle Agent Memory Into a Density Bet With No Wake-Up Test
Trending
October 5, 2026
How to Use frozendict in Python 3.15 to Freeze Config and Cache Dictionary Arguments
October 5, 2026
Kubernetes Node Swap Turns Idle Agent Memory Into a Density Bet With No Wake-Up Test
October 5, 2026
Denmark Says 8.8 Million Population Register Records Were Pulled Through One Company’s Lawful Access
October 5, 2026
How to Prepare Your Python Code for the Python 3.15 UTF-8 Default and Fix Windows Encoding Bugs
October 5, 2026
BT’s TalkTalk Rescue Turns Telecom Continuity Into a New Merger-Control Ground
October 5, 2026
Google Stops Accepting Product Bug Reports for Its Open-Source Bounty, Citing Automated Submissions

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026