Python 3.15 Lazy import
Table of Contents
Importing lazily?
The first beta preview of Python 3.15 dropped earlier this May. I was largely unaware of it, until I saw a blog post on some interesting features that 3.15 introduced.
The most important feature for the casual Python developer like me would definitely be the lazy import syntax. Note, however, lazy import is already a known concept in Python since forever. Take this very contrived example:
import pandas as pd
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
app = FastAPI()
@app.get("/ping")
def ping():
return {"status": "ok"}
# load a csv file given by the `name` parameter
@app.get("/data")
def get_data(name: str):
# Error handling omitted for brevity
df = pd.read_csv(name)
return df.to_dict(orient="records")
Large libraries like pandas may take awhile for the interpreter to fully parse & load. Some other libraries may run a bit of logic or even download data
ðŸ’
ðŸ’
spaCy do it, but ideally you should predownload model on prod
when you import it. You might want the server to be ready to serve the ping endpoint ASAP, at the expense of a bit of delay when calling GET /data for the first time.
# remove the following top level import
# import pandas as pd
# ...
@app.get("/data")
def get_data(name: str):
import pandas as pd
df = pd.read_csv(name)
return df.to_dict(orient="records")
The effect compounds if you have to load a few large libraries, but they lie on different execution path.
@app.get("/data")
def get_data(name: str):
import pandas as pd
@app.post("/mat_mul")
def do_matrix_mul(mat1: list[list[int]], mat2: list[list[int]]):
import numpy as np
The new syntax
The syntax we have above works fine, it just get a bit cluttered and repetitive if you need to do lazy-importing in several places:
@app.post("/calc1")
def do_calculation_1(mat):
import numpy as np
# ...
@app.post("/calc2")
def do_calculation_2(mat):
import numpy as np
# ...
# imagine 5 more endpoint in the file
Note that this doesn’t means numpy has to be imported twice. Once a module has been successfully parsed by Python, it is cached in the global module registry, and the second import invocation just load the module reference from memory.
The lazy import syntax solves this elegantly
lazy import numpy as np
@app.post("/calc1")
def do_calculation_1(mat):
# do something with np
@app.post("/calc2")
def do_calculation_2(mat):
# do something with np
The two snippets have almost the exact same behavior. The first endpoint of the two will trigger the numpy import when called. The benefit is better code organization.
Does lazy import matter?
Let’s test everything out a bit. First I’ll have the following sort of test setup to measure the time to run two functions
def func1():
pd.DataFrame([1, 2])
def func2():
pd.DataFrame([1, 2])
# This part is similar on all tested files
def main():
while True:
choice = input("Enter 1 or 2: ").strip()
if choice == "1":
start = time.perf_counter()
func1()
elapsed = time.perf_counter() - start
elif choice == "2":
start = time.perf_counter()
func2()
elapsed = time.perf_counter() - start
else:
print("Invalid choice")
return
print(f"Function execution time: {elapsed:.4f}s\n")
if __name__ == "__main__":
main()
Eager import
# eager.py
import time
start = time.perf_counter()
import pandas as pd
elapsed = time.perf_counter() - start
print(f"Pandas import: {elapsed:.4f}s\n")
# ...
$ uv run eager.py
Pandas import: 0.1373s
Enter 1 or 2: 1
Function execution time: 0.0010s
Enter 1 or 2: 2
Function execution time: 0.0006s
Enter 1 or 2: 1
Function execution time: 0.0005s
^C
Import inside function
What about importing inside each functions
# inline_lazy.py
def func1():
start = time.perf_counter()
import pandas as pd
elapsed = time.perf_counter() - start
print(f"Pandas import: {elapsed:.4f}s")
pd.DataFrame([1, 2])
def func2():
start = time.perf_counter()
import pandas as pd
elapsed = time.perf_counter() - start
print(f"Pandas import: {elapsed:.4f}s")
pd.DataFrame([1, 2])
$ uv run inline_lazy.py
Enter 1 or 2: 1
Pandas import: 0.1643s
Function execution time: 0.1644s
Enter 1 or 2: 2
Pandas import: 0.0000s
Function execution time: 0.0006s
Enter 1 or 2: 1
Pandas import: 0.0000s
Function execution time: 0.0003s
Enter 1 or 2: 2
Pandas import: 0.0000s
Function execution time: 0.0006s
^C
All ran as expected
Lazy import syntax
import time
start = time.perf_counter()
lazy import pandas as pd
elapsed = time.perf_counter() - start
print(f"Pandas import: {elapsed:.4f}s\n")
def func1():
pd.DataFrame([1, 2])
def func2():
pd.DataFrame([1, 2])
$ uv run lazy_import.py
Pandas import: 0.0000s
Enter 1 or 2: 1
Function execution time: 0.1645s
Enter 1 or 2: 2
Function execution time: 0.0005s
Enter 1 or 2: 1
Function execution time: 0.0006s
Enter 1 or 2: 2
Function execution time: 0.0005s
Lazy import can visibly reduce the time until the application is ready to serve (0.1645s -> 0s). The new syntax behave practically the same as the old method, while allowing us to consolidate all import call into one.
The case of circular import
One of my coworker point out that he usually use lazy import to get around circular import. It goes something like this
# main.py ##############################################
from a_module import func_a
def main():
choice = input("Call func_a? (Y/N): ").strip().lower()
if choice == "y":
func_a()
if __name__ == "__main__":
main()
# a_module.py ##########################################
from b_module import func_b
def func_a():
print("calling func_a")
func_b()
def util_func_a():
print("calling util_func_a")
# b_module.py ##########################################
from a_module import util_func_a
def func_b():
print("calling func_b")
util_func_a()
Of course, running this snippet right away won’t work. No surprise here.
$ uv run main.py
Traceback (most recent call last):
File "$DIR/main.py", line 1, in <module>
from a_module import func_a
File "$DIR/a_module.py", line 1, in <module>
from b_module import func_b
File "$DIR/b_module.py", line 1, in <module>
from a_module import util_func_a
ImportError: cannot import name 'util_func_a' from 'a_module', ....
The mental model of circular import in Python is really simple. The process goes like this:
- Interpreter run
main.py. mainimporta_module. Interpreter doesn’t seea_modulein the import registry. Interpreter runa_module.pya_moduleimportb_module. Interpreter doesn’t seeb_modulein the import registry. Interpreter runb_module.pyb_moduleimporta_module. Interpreter see thata_moduleis being imported but not yet done. -> Interpreter throw circular import error
The workaround
My coworker would usually workaround this issue by moving import inside the function
# b_module.py ##########################################
def func_b():
from a_module import util_func_a
print("calling func_b")
util_func_a()
$ uv run main.py
Call func_a? (Y/N): y
calling func_a
calling func_b
calling util_func_a
Workaround in lazy import syntax
My coworker, however, posed a question: would the lazy import syntax keep the workaround possible? In other word, will the snippet below work?
# b_module.py ##########################################
lazy from a_module import util_func_a
def func_b():
print("calling func_b")
util_func_a()
The answer is YES ✅
$ uv run main.py
Call func_a? (Y/N): y
calling func_a
calling func_b
calling util_func_a
If you find yourself reaching for this workaround, remember that circular import is usually indicative that your codebase design needs some improvement.
You should probably re-arrange your codebase a little, maybe refactor out that utility function into a different file.
Fin
This is indeed a very interesting addition to Python.
Man I suck when it comes to writing conclusion