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
Performance impact

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.
  • main import a_module. Interpreter doesn’t see a_module in the import registry. Interpreter run a_module.py
  • a_module import b_module. Interpreter doesn’t see b_module in the import registry. Interpreter run b_module.py
  • b_module import a_module. Interpreter see that a_module is 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
Should we actually do this workaround though?

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