AyushDoesDev. / Journal
All notes
Engineering notes··7 min read

Python 3.15 lazy imports: faster startup, later surprises

Python 3.15 adds explicit lazy imports. Learn where they help, how they shift errors and side effects, and how to adopt them without hiding production problems.

Share

The startup cost you keep paying

A Python process can spend a surprising amount of time doing work before it handles its first useful task. Importing a module is not just finding a file: Python loads it and executes its top-level code. That can pull in a long chain of dependencies, even when the command or request will never use most of them.

This is easy to notice in command-line tools. A user who runs `mytool --help` should not have to wait for a machine-learning runtime, cloud SDK or report exporter that the help screen never touches. The same issue can affect test discovery, short-lived workers and serverless functions.

Before changing imports, find out whether they are actually part of the delay. Python's `-X importtime` option reports the time spent importing modules, including cumulative time from nested imports. It is a useful starting point, not a substitute for measuring the complete command.

What Python 3.15 adds

Python 3.15 introduced explicit lazy imports through the `lazy` soft keyword. Python 3.15.0 became stable on 9 October 2026; the 3.15.2 maintenance release followed on 25 October. The feature is opt-in: ordinary imports keep their existing eager behavior.

Instead of loading a module at the import statement, Python creates a lightweight lazy binding and loads the module when the name is first used. The syntax stays near the top of the file, so the dependency remains visible without paying its full loading cost immediately.

  • lazy import json
  • lazy from pathlib import Path
  • The first access to json or Path triggers the relevant module load; imports not marked lazy remain eager by default.

The cost moves; it does not disappear

A lazy import is a decision about when work happens. If the code path never uses the imported name, the module may never load in that process. That is the useful case: a help command can avoid initializing optional features, and a worker can avoid loading a subsystem it does not need.

If the code does use the name, the first access pays the import cost at that point. A typo or missing dependency that would have failed during startup can now fail later, when the feature is first exercised. Python's traceback includes the original lazy import and the access that triggered resolution, but the failure still needs to be handled.

The Python proposal describes the steady-state access as being optimized after the lazy binding is resolved. In practical terms, the main trade-off is usually not a permanent penalty on every access; it is where the initial loading cost and any import-time failure appear.

Import-time side effects need special care

The most important migration risk is code that relies on importing a module to do something. A plugin may register itself through a decorator. A package may install a monkey patch, register a serializer, or populate a global registry at import time. With a lazy import, that work does not happen until the imported name is first used.

That can change application behavior even when every import eventually succeeds. A registry may be empty when startup code reads it. Two modules may run their initialization in a different order. A feature may appear to work in a developer session because another code path happened to load the module first.

Prefer explicit setup functions for important initialization. Call plugin discovery, registration and configuration deliberately during application startup rather than depending on an import being executed as a side effect. Lazy imports also do not automatically fix circular dependencies; they only help when the cross-module reference is not accessed while the modules are initializing.

Where lazy imports are a good fit

The strongest candidates are optional branches of a program: command-line subcommands, export formats, diagnostic tools, rarely used integrations and developer utilities. These programs often start, do one small task and exit. Avoiding imports for unused features can improve the experience without changing the main code structure.

For a long-running API server, be more deliberate. Lazy loading may shorten process startup but move the same initialization into the first request that touches a feature. That can create a cold first hit or a latency spike after a deployment. If a route is latency-sensitive, keeping its dependencies eager—or deliberately warming that path before marking the instance ready—may be the better choice.

The same distinction applies to serverless workloads. If an invocation does not use an optional dependency, deferring it can help. If every invocation needs it, lazy loading may simply move the cost from initialization into request handling.

A rollout that measures the right thing

Start with one process and one optional dependency. Capture a baseline with the exact command users run, then mark only the import that is not needed on the common path. Repeat the measurement after the change under the same environment. Do not assume a smaller import trace automatically means a faster user-visible operation.

For a CLI, compare both `--help` and the real subcommand that needs the dependency. For an API, measure process startup, readiness time, and the first request to each affected route separately. Also watch memory after startup and after the optional feature is used. The useful result is a change in the latency and resource profile of the real workload—not a benchmark number copied from a language proposal.

Run tests that exercise the first-use path, missing optional dependencies, plugin discovery, module registration and concurrent access where relevant. Keep critical imports eager until you have evidence that moving them is safe. Adopt the feature incrementally and review the resulting import graph rather than enabling global lazy mode as a shortcut.

Compatibility and syntax limits

The `lazy` keyword is Python 3.15 syntax, so older interpreters cannot parse a file that uses it. For code that must also run on earlier Python versions, PEP 810 describes a compatibility mechanism: define `__lazy_modules__` as a collection of fully qualified module names before the relevant imports. Python 3.15 treats matching imports as lazy; older versions ignore the declaration and import normally.

There are also deliberate restrictions. The `lazy` keyword is only allowed at module scope. It cannot be used inside a function, class body or `try`/`except`/`finally` block, and wildcard imports and `__future__` imports cannot be lazy. Check the language reference and PEP before building migration tooling around the syntax.

The practical takeaway

Python 3.15 makes lazy imports a first-class, explicit tool. They are worth considering when a process regularly starts with modules it may never use—especially CLI tools and optional features. They are not a universal startup switch, and they do not make expensive initialization free.

Profile first, defer only imports that are genuinely optional on the startup path, and test the point where each module is first used. For backend services, the right question is not simply whether the process starts faster. It is whether the complete user-facing operation becomes faster without making failures, initialization or first-request latency harder to reason about.