Python 3.15 adds a lazy keyword for imports, a sampling profiler called Tachyon, two builtins (frozendict and sentinel), unpacking inside comprehensions, and a JIT compiler that is still off by default. I installed 3.15.0 and measured each of them. Every command and every output below is from my own runs, and the files are short enough to copy.
Result first
| What | Before | After |
|---|---|---|
| Startup of a script with nine imports | 45.0 ms | 11.3 ms with lazy |
The same script, unchanged, with -X lazy_imports=all | 45.0 ms | 11.3 ms |
| Startup of an empty script | 11.4 ms | |
| Duplicate check over 40,000 orders, list to set | 3.98 s | 0.10 s |
| Integer loop, JIT off and on | 5.26 s | 2.33 s |
| Method calls on objects, JIT off and on | 1.93 s | 1.71 s |
| Dictionary counting, JIT off and on | 1.93 s | 2.10 s |
| JSON encode and decode, JIT off and on | 0.62 s | 0.62 s |
Two things stand out. A script with nine lazy imports starts as fast as a script with no imports at all. And the JIT result depends on the code: in my four tests it went from 2.3 times faster to 9% slower.
Setup
- CPython
3.15.0, a standalone build unpacked into a folder, so the systempython3stays on 3.14. - An Apple M4 Max with macOS 26.6.
- The profiler runs in a
debian:trixie-slimcontainer with the Linux build of the same version. On macOS the sampling profiler stops with “Tachyon needs elevated permissions to profile processes” and suggestssudo, which I did not want to use.
Where the startup time goes
This is the test file. It imports nine standard library modules and prints one word.
import argparse
import asyncio
import decimal
import email.message
import http.server
import json
import sqlite3
import unittest
import xml.dom.minidom
print("ready")eager.py
-X importtime prints one line per module with its own time and the time including everything it pulls in, in microseconds. Sorted by the second column, the end of the list looks like this:
python3.15 -X importtime eager.py 2>&1 | sort -t'|' -k2 -n | tail -6
import time: 510 | 6544 | http.server
import time: 829 | 8403 | logging
import time: 478 | 8880 | concurrent.futures._base
import time: 240 | 9427 | concurrent.futures
import time: 753 | 25319 | asyncio.base_events
import time: 253 | 30213 | asynciooutput
asyncio alone is 30 ms of the 45. Its own code is a quarter of a millisecond. The rest is what it imports: concurrent.futures, logging, traceback, inspect and others. The other eight lines of the file add up to much less than this one.
The lazy keyword
The second file is the same with lazy in front of each import.
lazy import argparse
lazy import asyncio
lazy import decimal
lazy import email.message
lazy import http.server
lazy import json
lazy import sqlite3
lazy import unittest
lazy import xml.dom.minidom
print("ready")lazy.py
Bash’s time rounds to a millisecond and moves a little between runs, so I timed 20 starts of each file with a small script and took the median.
import statistics, subprocess, sys, time
def run(args):
times = []
for _ in range(20):
start = time.perf_counter()
subprocess.run([sys.executable, *args], stdout=subprocess.DEVNULL, check=True)
times.append((time.perf_counter() - start) * 1000)
return f"median {statistics.median(times):5.1f} ms min {min(times):5.1f} max {max(times):5.1f}"
print("empty script ", run(["-c", "pass"]))
print("eager.py ", run(["eager.py"]))
print("lazy.py ", run(["lazy.py"]))
print("eager.py, -X all ", run(["-X", "lazy_imports=all", "eager.py"]))startup.py
empty script median 11.4 ms min 10.7 max 13.0
eager.py median 45.0 ms min 43.3 max 48.0
lazy.py median 11.3 ms min 10.4 max 13.2
eager.py, -X all median 11.3 ms min 10.6 max 13.9output
The interpreter itself needs about 11 ms to start and exit. lazy.py costs the same, so nine lazy imports that are never used cost nothing I can measure.
The last row is the unchanged eager.py. The option -X lazy_imports=all (or the environment variable PYTHON_LAZY_IMPORTS=all) makes every import in the program lazy, including the imports inside libraries. That is the quick way to find out how much a tool could gain before editing a single file.
What sits in the variable
A lazy import binds the name to a placeholder. I read it through globals(), because using the name in the normal way would load the module.
import sys
lazy import json
lazy from decimal import Decimal
print(type(globals()["json"]))
print(globals()["json"])
print(type(globals()["Decimal"]))
print("decimal" in sys.modules)
Decimal("1.5")
print("decimal" in sys.modules)ph.py
<class 'lazy_import'>
<lazy_import 'json'>
<class 'lazy_import'>
False
Trueoutput
lazy from decimal import Decimal works too. The decimal module is loaded on the line that calls Decimal, and from then on the name is the real class.
Where lazy is not allowed
I tried the keyword in four places where the compiler refuses it.
def f():
lazy import json # SyntaxError: lazy import not allowed inside functions
class A:
lazy import json # SyntaxError: lazy import not allowed inside classes
try:
lazy import json # SyntaxError: lazy import not allowed inside try/except blocks
except ImportError:
pass
lazy from os import * # SyntaxError: lazy from ... import * is not allowed
The try rule is the one to remember. A common pattern is try: import ujson with a fallback in except ImportError. With a lazy import the ImportError would be raised later, on first use, far away from that except, so the language does not let you write it.
lazy is a soft keyword. Old code that has a variable named lazy still runs:
lazy = 3
print(lazy + 1) # 4
The error moves to first use
lazy import no_such_module
print("started fine")
no_such_module.run()typo.py
started fine
ModuleNotFoundError: No module named 'no_such_module'output
The program starts, prints its line, and fails on line 4. With a normal import it would fail on line 1 before doing any work. For a command line tool that is a fair trade. For a service, a missing dependency could now appear on the first request that needs it, so a start-up check that touches each lazy module is worth having.
One file for 3.14 and 3.15
lazy import json is a syntax error on Python 3.14, so a library that supports both versions cannot use the keyword yet. There is a second spelling for that case: a module-level list named __lazy_modules__.
__lazy_modules__ = ["json"]
import sys
import json
print("json" in sys.modules)
json.dumps({})
print("json" in sys.modules)lm.py
False
Trueoutput
On 3.15 the plain import json behaves as a lazy one. An older Python sees an ordinary variable and an ordinary import: the same file prints True and True on my 3.14.7.
Tachyon, the sampling profiler
The script below looks for duplicate orders and takes four seconds.
import hashlib
import json
def load_orders():
return [{"id": i, "items": list(range(i % 50))} for i in range(40_000)]
def fingerprint(order):
return hashlib.sha256(json.dumps(order).encode()).hexdigest()
def find_duplicates(orders):
seen = []
for order in orders:
fp = fingerprint(order)
if fp not in seen:
seen.append(fp)
return len(orders) - len(seen)
print(find_duplicates(load_orders()))slow.py
The new profiler lives in profiling.sampling. It stops nothing and changes nothing in the script. It reads the call stack of the process about a thousand times per second and counts where it was.
python3.15 -m profiling.sampling run slow.py
Captured 3971 samples in 3.97 seconds
Sample rate: 999.85 samples/sec
Error rate: 2.24
Profile Stats:
nsamples sample% tottime (s) cumul% cumtime (s) filename:lineno(function)
3817/3817 98.3 3.817 98.3 3.817 slow.py:14(find_duplicates)
23/33 0.6 0.023 0.9 0.033 slow.py:13(find_duplicates)
16/19 0.4 0.016 0.5 0.019 slow.py:5(load_orders)
6/10 0.2 0.006 0.3 0.010 slow.py:8(fingerprint)output
3,817 of 3,971 samples are on line 14, if fp not in seen. seen is a list, so each check walks the list, and the list grows to 40,000 entries. The hashing and the JSON encoding, which look expensive, are under one percent.
The share on line 14 is 98.3% in this run and was 97.6% in an earlier one. A sampling profiler gives slightly different counts each time.
The old profiler on the same script
The profiler that used to be cProfile is now also available as profiling.tracing. It records every function call.
python3.15 -m profiling.tracing -s tottime slow.py
444023 function calls (443891 primitive calls) in 3.984 seconds
ncalls tottime percall cumtime percall filename:lineno(function)
1 3.828 3.828 3.963 3.963 slow.py:10(find_duplicates)
40000 0.053 0.000 0.053 0.000 encoder.py:207(iterencode)
40000 0.023 0.000 0.132 0.000 slow.py:7(fingerprint)output
It finds the same function, and it reports line 10, the def line. Which of the six lines inside the function is slow is left to the reader. The sampling profiler answered that directly.
I expected the tracing profiler to slow the script down, and here it did not: 3.98 s plain, 4.02 s traced, 4.06 s sampled. This script makes only 444,000 function calls and spends its time inside one of them. A program made of millions of small calls would pay more per call under tracing.
Other ways to run it
From python3.15 -m profiling.sampling --help:
python3.15 -m profiling.sampling attach 1234 # a process that is already running
python3.15 -m profiling.sampling run --live slow.py # a top-like view in the terminal
python3.15 -m profiling.sampling run --flamegraph -o flame.html slow.py
python3.15 -m profiling.sampling run -r 5khz -d 30 slow.py
I ran the flame graph one. It wrote a single 630 KB HTML file that opens in a browser. I did not try attach and --live for this post.
The fix
diff slow.py fast.py
11c11
< seen = []
---
> seen = set()
15c15
< seen.append(fp)
---
> seen.add(fp)output
A set finds a value by its hash and does not walk anything. The script goes from 3.98 s to 0.10 s.
frozendict
a = frozendict(host="db", port=5432)
b = a | {"port": 3306}
print(a)
print(b)
print(a == {"host": "db", "port": 5432})
print(isinstance(a, dict))
print(hash(a) == hash(frozendict(port=5432, host="db")))
try:
hash(frozendict(tags=["x"]))
except TypeError as e:
print("TypeError:", e)fd.py
frozendict({'host': 'db', 'port': 5432})
frozendict({'host': 'db', 'port': 3306})
True
False
True
TypeError: unhashable type: 'list'output
Four things I wanted to know before using it:
|returns a newfrozendictand leaves the first one as it was. That is how you “change” one.- It compares equal to a normal
dictwith the same content. - It is not a subclass of
dict. Code that checksisinstance(x, dict)will turn it away;collections.abc.Mappingis the check that accepts both. - The hash ignores key order, so two frozendicts built in a different order are the same dictionary key. It can only be hashed when all of its values can: a list inside it brings back
unhashable type.
Assigning to a key raises TypeError: 'frozendict' object does not support item assignment.
sentinel
None is often a real value: “set the nickname to nothing”. A function then needs another marker for “this argument was not passed”. The usual trick was MISSING = object(), which prints as <object object at 0x...> in every signature and log line.
MISSING = sentinel("MISSING")
def update(name, nickname=MISSING):
if nickname is MISSING:
return f"{name}: nickname not touched"
return f"{name}: nickname set to {nickname!r}"
print(update("ada"))
print(update("ada", None))missing.py
ada: nickname not touched
ada: nickname set to Noneoutput
A few properties, checked one by one:
print(repr(MISSING)) # MISSING
print(copy.deepcopy(MISSING) is MISSING) # True
print(pickle.loads(pickle.dumps(MISSING)) is MISSING) # True
print(sentinel("MISSING") is MISSING) # False
It prints as its name, and it survives a copy and a pickle round trip as the same object, which object() does not. The last line is the one to be careful with: calling sentinel("MISSING") a second time makes a different object. Define it once in a module and import it from there.
Unpacking in comprehensions
pages = [[1, 2], [3, 4], [5]]
print([*page for page in pages])
settings = [{"debug": False}, {"port": 80}, {"debug": True}]
print({**s for s in settings})flat.py
[1, 2, 3, 4, 5]
{'debug': True, 'port': 80}output
[*page for page in pages] gives the same list as the double loop [x for page in pages for x in page], which many people have to look up each time. In the dictionary form a later value replaces an earlier one, so debug ends up True. The star also works in a set comprehension and in a generator expression:
print({*page for page in pages}) # {1, 2, 3, 4, 5}
gen = (*page for page in pages)
print(next(gen), next(gen)) # 1 2
An error message that looks one level down
from dataclasses import dataclass
@dataclass
class Customer:
email: str
@dataclass
class Order:
customer: Customer
order = Order(Customer("ada@example.com"))
print(order.email)shop.py
AttributeError: 'Order' object has no attribute 'email'. Did you mean '.customer.email' instead of '.email'?output
Earlier versions suggested names that were spelled almost the same. This one looked inside the object’s own attributes and found the name there.
The JIT on four workloads
The JIT compiler ships in the 3.15 builds, switched off. Setting PYTHON_JIT=1 enables it for one run.
python3.15 -c "import sys; print(sys._jit.is_enabled())" # False
PYTHON_JIT=1 python3.15 -c "import sys; print(sys._jit.is_enabled())" # True
The first test is a pure integer loop: the longest Collatz chain under one million.
import time
def collatz_steps(n):
steps = 0
while n != 1:
n = n // 2 if n % 2 == 0 else 3 * n + 1
steps += 1
return steps
start = time.perf_counter()
longest = max(range(1, 1_000_000), key=collatz_steps)
print(f"{longest} in {time.perf_counter() - start:.2f} s")bench.py
A loop like this is where a JIT has the most to gain, so I wrote three more scripts that look more like ordinary application code.
import time
from dataclasses import dataclass
@dataclass
class Item:
price: int
qty: int
def total(self):
return self.price * self.qty
class Order:
def __init__(self, items):
self.items = items
def total(self):
return sum(item.total() for item in self.items)
orders = [Order([Item(i % 90 + 10, j % 5 + 1) for j in range(20)]) for i in range(50_000)]
start = time.perf_counter()
grand = 0
for _ in range(50):
for order in orders:
grand += order.total()
print(f"{grand} in {time.perf_counter() - start:.2f} s")w_objects.py
import time
words = [f"w{(i * 7919) % 5000}" for i in range(4_000_000)]
start = time.perf_counter()
counts = {}
for _ in range(8):
for word in words:
counts[word] = counts.get(word, 0) + 1
top = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[:3]
print(f"{len(counts)} in {time.perf_counter() - start:.2f} s")w_words.py
import json, time
orders = [{"id": i, "items": list(range(i % 50)), "note": "x" * (i % 30)} for i in range(40_000)]
start = time.perf_counter()
for _ in range(10):
text = json.dumps(orders)
back = json.loads(text)
print(f"{len(back)} in {time.perf_counter() - start:.2f} s")w_json.py
Each script ran five times with PYTHON_JIT=0 and five times with PYTHON_JIT=1. These are all the runs, in seconds:
bench.py PYTHON_JIT=0 5.25 5.24 5.24 5.27 5.26
bench.py PYTHON_JIT=1 2.32 2.33 2.32 2.33 2.33
w_objects.py PYTHON_JIT=0 1.93 1.92 1.95 1.93 1.94
w_objects.py PYTHON_JIT=1 1.71 1.71 1.70 1.75 1.73
w_words.py PYTHON_JIT=0 1.93 1.93 2.07 1.94 1.92
w_words.py PYTHON_JIT=1 2.15 2.10 2.26 2.08 2.07
w_json.py PYTHON_JIT=0 0.63 0.62 0.62 0.62 0.63
w_json.py PYTHON_JIT=1 0.61 0.62 0.63 0.62 0.63output
| Workload | Median, JIT off | Median, JIT on | Change |
|---|---|---|---|
| Integer loop | 5.25 s | 2.33 s | 2.3 times faster |
| Method calls on objects | 1.93 s | 1.71 s | 11% less time |
| Dictionary counting | 1.93 s | 2.10 s | 9% more time |
| JSON encode and decode | 0.62 s | 0.62 s | no change |
The JSON result has a simple reason: json.dumps and json.loads are C code, and the JIT compiles Python bytecode. There is almost no Python in that loop to compile. I do not have an explanation for the dictionary loop being slower. It was slower in all five runs.
The release notes report an 11 to 12% speedup on AArch64 macOS and 7 to 8% on x86-64 Linux, both from the pyperformance suite, with single benchmarks ranging “from roughly 15% slowdown to over 100% speedup”. My four scripts fall inside that range, and the object test lands on the macOS figure. The heading of that section still calls the JIT experimental.
What this does not show
- A real service. All four JIT tests are single-threaded scripts of a few seconds. A web application spends much of its time in C extensions, the database driver and the network, where the JSON result is the more likely picture.
- Other machines. Everything is from one Apple Silicon laptop. I did not test on x86-64, where the release notes give the lower average.
- Memory. I did not measure how much memory the JIT or the lazy placeholders use.
- Lazy imports in a large code base. My test file is ten lines. Libraries that run code at import time (registering plugins, patching other modules) can behave differently when that code runs later, and the
allmode makes their imports lazy as well. - The free-threaded build. Not tested.