Lazy imports are a fix for circular imports, not a startup trick.

Python 3.15 ships the lazy keyword this month. Most takes I've seen file it under startup speed. I think the bigger win is that the import-inside-a-function workaround finally gets to go back to the top of the file, where your dependencies belong.

Python 3.15 Lazy Imports Are About Circular Imports, Not Startup Time

Image: METAHEURISTIC

Alexander Myasoedov

+Alexander Myasoedov Alexander writes about the operational side of shipping production AI - agents, retrieval, evals, and the guardrails that keep them from going sideways.

You know the line. It sits three levels deep inside a method, usually with a comment above it that says # avoid circular import, sometimes with no comment at all because whoever wrote it assumed you’d figure it out. Every Python codebase I’ve worked on past a certain size has a handful of these. They work. Nobody loves them. And when you open the file to see what the module depends on, the header lies to you, because half the real dependencies are hiding in function bodies.

Python 3.15 is due on October 9 and it ships PEP 810, explicit lazy imports. A lot of what I’ve read about it, including a LinkedIn post I was asked about recently, treats it as a startup-time feature: put lazy in front of your imports, your CLI boots faster, done. That’s true and it’s fine. I just don’t think it’s the interesting part.

The misconception

Here’s the take I keep running into, said more or less directly: imports are bookkeeping. You add them so the linter is happy and CI goes green, and half of them are only there for a type hint or a code path that almost never runs. So lazy gets sold as a way to make that dead weight free. Import whatever you like, if you never use it you never pay.

That’s not what imports are, and it’s not what this feature is for. An import is a statement about what your module needs to work. It has a real cost at load time, which matters, especially when modules are used dynamically and some paths only get hit occasionally. But it’s also the single place a reader looks to understand what a file touches. When we push imports into function bodies to dodge a cycle, we pay for it in readability, not speed. lazy lets us stop paying that.

What it actually does

The syntax is one soft keyword:

lazy import json
lazy from app.orders import Order

At that line Python records that you want json but doesn’t load it. The name gets bound to a placeholder object. The first time your code actually touches the name, the import runs, the placeholder is swapped for the real module, and from then on it’s a normal global. The PEP says the overhead after that first touch is zero, because the interpreter’s specialization rewrites the lookup once the real object is in place.

I installed 3.15.0rc2 to poke at it rather than trust my reading of the PEP. Before first use, json isn’t in sys.modules and globals()["json"] is an object of type lazy_import. After json.dumps(1) it’s a plain module. If the module doesn’t exist, the lazy import line passes without complaint and you get ModuleNotFoundError at the line that first uses it. That’s the trade you’re signing up for, and it’s the right one to keep in mind for everything below.

The circular import you already have

Here’s the smallest version of the problem. Two modules, each needs the other’s class:

# app/orders.py
from app.customers import Customer

class Order:
    def __init__(self, customer: Customer, total: int):
        self.customer = customer
        self.total = total
# app/customers.py
from app.orders import Order

class Customer:
    def __init__(self, name: str):
        self.name = name

    def place(self, total: int) -> Order:
        return Order(self, total)

Import app.orders and you get the classic ImportError: cannot import name 'Order' from partially initialized module 'app.orders' (most likely due to a circular import). This isn’t bad design you can refactor away in an afternoon. It’s two concepts that really do know about each other, which is normal in object-oriented code. You can merge the files, pull out a third module, or invert a dependency, and sometimes you should. But often the honest shape of the domain is just this.

So you do the usual thing: delete the top-level import in customers.py and put from app.orders import Order inside place(). It works because by the time anyone calls place(), both modules have finished loading. The import runs once, gets cached in sys.modules, and every later call is a dictionary lookup. The cost is that the file header no longer tells the truth.

With 3.15 the fix is one word, and it stays at the top:

# app/customers.py
lazy from app.orders import Order

I ran exactly this on rc2. Eager version: ImportError. Lazy version: Customer("ada").place(10) returns an Order. Nothing else changed.

Local import workaround

  • Import hidden inside the method
  • Header shows only some dependencies
  • Loads on first call, cached after
  • Works on every Python version

lazy import

  • Import declared at the top of the file
  • Header shows every dependency
  • Loads on first use, cached after
  • Needs 3.15, or __lazy_modules__ for a soft fallback

Look at the runtime behavior on both sides. It’s the same. The local import was already a lazy import, we just had to write it by hand and put it in the wrong place. That’s why I don’t buy the “it’s only about startup” framing. The mechanism existed. What 3.15 gives you is a way to say it where people will read it.

Where it stops helping: The PEP is explicit that lazy imports only fix a cycle if the circular reference isn’t used while the module is initializing. If customers.py instantiated an Order at module level, lazy wouldn’t save you. It defers the import, it doesn’t remove the dependency.

Platform code and optional deps

The second pattern I see a lot is conditional dependencies. A Windows backend, a macOS one, a Linux one. An optional accelerated library with a pure-Python fallback. Today these tend to get scattered: an import inside the function that needs it, another one inside a try block, a third behind a feature flag somewhere in the middle of the file.

lazy is allowed inside a plain if, so you can declare all of them up front and only pay for the one you touch:

import sys

if sys.platform == "win32":
    lazy import winreg
else:
    lazy import fcntl

One thing to know before you try the obvious next step: lazy is a SyntaxError inside functions, class bodies, and try/except blocks. That last one rules out the try: import fast_lib / except ImportError: import slow_lib fallback pattern, and it makes sense once you remember errors only show up on first use. There’s no ImportError to catch at the import line anymore.

Yes, it’s also faster

I’m not dismissing startup time. I made a module that eagerly imports asyncio, decimal, email.mime.multipart, http.client, sqlite3, xml.etree.ElementTree, csv and zipfile, then a copy with lazy in front of each line. On my laptop with rc2, -X importtime puts the eager one around 35 to 45 ms and the lazy one under half a millisecond. Of course it does, it isn’t loading anything yet.

The cost moves to first use. A lazy asyncio took about 22 ms the first time I touched an attribute, and effectively nothing the second time. If your process is a CLI that runs one subcommand out of twenty, or a worker that only hits the PDF path on 1% of jobs, that’s a trade I’d take every time. Load ten times faster, pay a small warm-up when the feature is actually used. For a long-running server that touches everything in the first request anyway, you mostly just moved the cost around.

Why I like that it’s a keyword

There’s a reasonable worry that making every import lazy would break code that relies on import side effects: plugin registration, monkey-patching at import time, modules that configure logging when loaded. That’s exactly why I’m glad 3.15 didn’t flip the default. You opt in per line. Old code runs the same. You don’t need to start the interpreter with a special flag to keep your app working.

The global switch exists for people who want to experiment. -X lazy_imports=all (or PYTHON_LAZY_IMPORTS=all) makes module-level imports lazy across the board, and I checked that it untangles the orders/customers cycle above without touching either file. There’s also __lazy_modules__, a list of module names at the top of a file that 3.15 treats as if you wrote lazy. On 3.14 it’s just an unused list, so libraries can adopt it without dropping older versions. I tried that too: lazy on rc2, ordinary eager import on 3.14.

Long term I wouldn’t be surprised if lazy becomes the default, with modules that need eager loading marking themselves as such. Haskell went much further and made evaluation itself lazy by default (I’ve written about where Haskell stands now), so it’s not a crazy idea. But Python has thirty years of import side effects to unwind first, and opt-in is the right way to start.

What it says about where Python is going

I think this is a beautiful piece of language design, and mostly for what it doesn’t do. It’s one soft keyword. lazy only means something at the start of an import statement, so lazy = 3 or a parameter called lazy still work fine, which I checked because I have code like that. It’s a small addition to the grammar, existing code is untouched, and the old spelling keeps working everywhere. A lot of language features in the last few years added real power. This one mostly takes away boilerplate.

And there’s a lot of boilerplate it takes away. Local imports to break cycles. Imports scattered into whichever function needs a platform-specific module. The if TYPE_CHECKING: block that exists only so an annotation can name a class without importing it at runtime. The PEP calls that last one out directly, and on rc2 a lazy from import used only in annotations stays unloaded until something actually inspects the annotations.

I’m not going to pretend the local import was a crime. It’s a reasonable pattern, and I’ve written hundreds of them. But I’d like to see a lot fewer of them, because the payoff is simple: you open any module in the project and the top of the file tells you everything it depends on. In 2026, when more and more of the people reading your code are agents that skim the header and guess the rest, a truthful import block matters more than it used to.

Mocks and pytest

This was the part I was most curious about, because test setups are where I hit circular imports most often. Conftest files import fixtures that import the app that imports config that imports something the fixtures also need.

Here’s what rc2 actually does, and the two tools don’t agree. mock.patch("svc.json") on a module that did lazy import json works the way you’d hope. Inside the with block your mock is used, json never lands in sys.modules, and when the block exits the original lazy placeholder is put back. Nothing gets loaded.

pytest’s monkeypatch.setattr is different. It reads the old value with getattr so it can restore it later, and getattr counts as first use. So the real module gets imported the moment you patch it, and after the test the attribute holds the real module, not the placeholder. Your test still passes. But if you were patching a lazy import specifically so a heavy or broken dependency would never load in tests, monkeypatch loads it anyway and mock.patch doesn’t. I wouldn’t call either one a bug. It’s just something you’ll want to know before it surprises you.

The thing to watch is the other direction. If a test relies on importing a module for its side effects, and that import is now lazy, the side effect won’t happen until something touches the name. That bug is going to cost somebody an afternoon.

If you’re a mid-level dev

Don’t start by sprinkling lazy over your codebase. Do this instead. Build a tiny service, anything with two or three modules that genuinely reference each other: orders and customers, users and teams, whatever. Hit the circular import. Fix it the old way, with a local import inside the method. Then put the import back at the top with lazy and look at the two versions next to each other. Run python -X importtime on both. Then write a test that patches one of the lazily imported names and check what’s in sys.modules before and after.

That’s an hour, and you’ll understand the feature better than most of the posts about it. Bonus round: do the same thing inside a pytest setup where the cycle runs through a conftest or a fixture, and patch the lazy name both ways, with mock.patch and with monkeypatch. That’s where the differences above stop being trivia.

If you got here searching

If you typed “python circular import fix” or “python lazy imports” into a search box, here’s the short version. On 3.15, a cycle where one side only needs the other inside functions or methods goes away with lazy from x import Y at the top of the file, instead of an import buried in the method. Platform-specific and optional dependencies can be declared up front inside a plain if. Mocks work, but mock.patch keeps the import lazy and monkeypatch.setattr triggers it. And the speedup is real, but it’s the least interesting reason to care.

What you’re looking for isn’t the speedup. It’s the moment you notice the dependency list at the top of the file is complete again.

Follow in Google

Make Metaheuristic a preferred source.

One tap and posts like this one surface higher in your Top Stories.

Work with us

Production AI, with guardrails.

Start with a fixed-scope AI Workflow Audit. We map the opportunity and quote a build.

Start a Discovery Sprint →