Python 3.15: Lazy Imports, Frozendict, and a Faster JIT
Python 3.15 release candidate: explicit lazy imports, the new frozendict and sentinel types, the Tachyon profiler, and real JIT benchmark numbers.
Import fifteen modules at the top of a script and you pay for all fifteen before your program does anything, whether it ends up using twelve of them or two. That tax has shaped how Python developers write code for years: imports buried inside functions, importlib tricks to defer loading, entire style guides built around avoiding a cost that shouldn't exist in the first place. Python 3.15, now at its first release candidate as of August 4, finally removes the tax instead of asking you to work around it.
This isn't a quiet point release padded with deprecation notices. It ships a new immutable dictionary type, a statistical profiler fast enough to run in production, error messages that guess what JavaScript developers meant to type, and a JIT compiler with benchmark numbers attached to it for the first time since the JIT landed in 3.13. Here's what's actually in it, and what's still missing.
Lazy Imports Get a Real Keyword
PEP 810 gives Python a lazy soft keyword that does what a decade of importlib workarounds tried to fake. Mark an import lazy, and Python builds a lightweight proxy instead of loading the module immediately. The real load happens on first access:
lazy import json
lazy from pathlib import Path
print("Starting up...") # json and pathlib not loaded yet
data = json.loads('{"key": "value"}') # json loads here
p = Path(".") # pathlib loads here
You get the readability of declaring every import at the top of the file without paying for modules your particular run never touches. If a lazily imported module fails to load, for example because it doesn't exist, Python raises the exception at the point of first use, not at import time, and the traceback names both the failed access and the original import line.
Two escape hatches exist for code you can't or don't want to annotate directly. The -X lazy_imports flag and PYTHON_LAZY_IMPORTS environment variable flip laziness on globally. For finer control, sys.set_lazy_imports_filter() takes a callable that decides module by module:
import sys
def myapp_filter(importing, imported, fromlist):
return imported.startswith("myapp.")
sys.set_lazy_imports_filter(myapp_filter)
sys.set_lazy_imports("all")
import myapp.slow_module # lazy (matches filter)
import json # eager (does not match filter)
The restriction worth knowing: lazy only works at module scope. Try it inside a function, a class body, or a try/except block, and Python raises SyntaxError. Star imports and __future__ imports can't be lazy either.
A Dictionary You Can Hash
PEP 814 adds frozendict to the builtins module: a dict that refuses modification after creation and, because of that, can serve as a dictionary key or live inside a set. It isn't a dict subclass. It inherits from object directly.
>>> a = frozendict(x=1, y=2)
>>> a['z'] = 3
Traceback (most recent call last):
TypeError: 'frozendict' object does not support item assignment
>>> b = frozendict(y=2, x=1)
>>> hash(a) == hash(b)
True
copy, decimal, json, marshal, pickle, pprint, and xml.etree.ElementTree already accept it, and eval()/exec() will take a frozendict as globals. If your code currently checks isinstance(arg, dict) and needs to recognize the new type too, swap it for isinstance(arg, (dict, frozendict)), or reach further and check isinstance(arg, collections.abc.Mapping) to catch MappingProxyType in the same pass.
sentinel() Retires a Twenty-Year-Old Hack
Every Python codebase has at least one _MISSING = object() line standing in for "no value was passed" in places where None is a legitimate answer. PEP 661 turns that pattern into a builtin. sentinel("NAME") creates a unique object that compares only to itself via is, prints something readable instead of <object object at 0x7f...>, and can be pickled when it's importable by module and name. Small feature, but it closes a gap nearly every serious Python project has quietly patched over on its own.
Tachyon: a Profiler for Systems You Can't Afford to Slow Down
PEP 799 reorganizes Python's profiling tools under a new profiling namespace. The old deterministic tracer, cProfile, moves to profiling.tracing (with cProfile kept as an alias, so nothing breaks). The interesting part is what's new: profiling.sampling, a statistical sampling profiler with the internal name Tachyon.
Deterministic profilers instrument every function call, which is precise and also slow enough that you'd never point one at production. Tachyon periodically samples stack traces from a running process instead, at rates up to 1,000,000 Hz, with close to zero overhead. You can attach it to a PID that's already running, run it against a script from the start, point it at a module, or grab a one-shot stack dump of every thread to diagnose something that's hung right now. It profiles wall-clock time, CPU time, GIL-holding time, or exception-handling time depending on what you're chasing, and it exports to flame graphs, Firefox Profiler's Gecko format, or an interactive HTML heatmap down to the line level.
The pitch, in short: the profiler you'd actually reach for when something is slow in a system you can't restart.
Frame Pointers, On by Default
PEP 831 turns on frame pointers by default wherever the platform supports them, using -fno-omit-frame-pointer and -mno-omit-leaf-frame-pointer. The payoff shows up outside your code entirely: native stack unwinding gets faster and more reliable for system profilers, debuggers, crash analysis, and eBPF-based observability tools. The flags propagate through sysconfig, so extension modules built against Python's own build configuration inherit frame pointers automatically. One caveat worth flagging if you maintain a build system: a single native component compiled without frame pointers can break stack unwinding for the entire Python process, so third-party build backends need to preserve these flags rather than silently drop them.
The JIT Finally Has Numbers Behind It
CPython's JIT debuted in 3.13 without much to show for itself, since the early revisions were about laying groundwork rather than delivering speed. 3.15 changes that, with a new tracing front end, register allocation, better generated machine code, and elimination of reference counts for some object classes.
| Platform | JIT vs. no-JIT (geometric mean) | Best case | Worst case |
|---|---|---|---|
| x86-64 Linux | 8-9% faster | Over 100% speedup on some benchmarks | Up to ~15% slower on unpack_sequence-style workloads |
| AArch64 macOS | 12-13% faster over the tail-calling interpreter | Over 100% speedup on some benchmarks | Up to ~15% slower on unpack_sequence-style workloads |
Those numbers come from the pyperformance benchmark suite, comparing JIT builds against a standard CPython interpreter built with every other optimization already enabled. The JIT isn't a universal win yet. It's worth testing against your actual workload before flipping it on for anything you run in production, and the core team has attached formal performance guidelines the JIT now has to clear before it graduates from experimental status.
Better Guesses When You Get the Method Name Wrong
Python's error messages keep getting sharper about what you probably meant. In 3.15, AttributeError suggestions now look through nested attributes, not just the object itself:
container.area
# AttributeError: 'Container' object has no attribute 'area'.
# Did you mean '.inner.area' instead of '.area'?
When Levenshtein-distance matching comes up empty, the interpreter checks a table of method names common in other languages and translates:
>>> [1, 2, 3].push(4)
AttributeError: 'list' object has no attribute 'push'. Did you mean '.append'?
>>> 'hello'.toUpperCase()
AttributeError: 'str' object has no attribute 'toUpperCase'. Did you mean '.upper'?
Call a mutable method on something immutable, and the hint points you at the mutable type instead of just failing:
>>> (1, 2, 3).append(4)
AttributeError: 'tuple' object has no attribute 'append'. Did you mean to use a 'list' object?
delattr() gets the same fuzzy-matching treatment for typos on the way out as getattr() already had on the way in.
Unpacking in Comprehensions
PEP 798 extends the */** unpacking syntax from function calls into comprehensions and generator expressions, replacing the usual itertools.chain() detour or an ugly nested comprehension:
>>> lists = [[1, 2], [3, 4], [5]]
>>> [*L for L in lists]
[1, 2, 3, 4, 5]
>>> dicts = [{'a': 1}, {'b': 2}, {'a': 3}]
>>> {**d for d in dicts}
{'a': 3, 'b': 2}
It works in generator expressions too, including async ones, so (*a async for a in agen()) flattens exactly the way you'd expect.
The Incremental Garbage Collector Experiment Ends
Python 3.14.0 through 3.14.4 shipped a new incremental garbage collector aimed at cutting the pause time needed to collect garbage. In production, it did something else: users reported significant memory pressure, in some cases dramatic. Python reverted to the generational collector from 3.13 starting in 3.14.5, and 3.15 ships with that same reverted collector, not the incremental one. The incremental collector may come back once the memory behavior gets fixed, but it isn't part of this release.
Smaller Changes Worth Knowing About
- UTF-8 is now the default encoding, everywhere. PEP 686 means
open('file.txt')without an explicitencodingargument now assumes UTF-8 rather than deferring to whatever the operating system's locale happens to be. You can opt out withPYTHONUTF8=0or-X utf8=0, but for anyone shipping code that needs to run identically across Windows, Linux, and macOS, this closes a long-running source of subtle bugs. math.integercollects the integer-only functions.gcd(),isqrt(),lcm(),comb(),perm(), andfactorial()move to a dedicatedmath.integermodule under PEP 791. The oldmath.gcd()-style aliases still work but are soft-deprecated in favor of the new location.- The Stable ABI now covers free-threaded builds. PEP 803 introduces
abi3t, letting C extensions target the Stable ABI while staying compatible with free-threaded ("no-GIL") CPython. Getting there requires real source changes, not a recompile with different headers, and as of this release the major build tools (Setuptools, meson-python, scikit-build-core, Maturin) don't supportabi3tyet.
What This Release Doesn't Cover
Python 3.15 doesn't touch the GIL removal timeline directly. Free-threaded builds keep maturing on their own schedule, and this release is about making the ecosystem around them (the Stable ABI work above) more usable, not about changing free-threading's default status. It also doesn't ship any changes to the packaging or dependency-resolution story; pip, uv, and friends move on their own release cycles. And despite the JIT's new numbers, this release doesn't promise the JIT will make your specific workload faster. The geometric mean hides a real spread, and some benchmarks get measurably slower.