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.



