Python 3.15 beta 1 is out! This is a really impactful release, with some really big additions. A new lazy import system, a powerful sampling profiler, not one but two new builtins, the usual color/types/errors updates, and lots of key changes for developers.
Lazy imports
I talked about this in flake8-lazy. In short, there’s now two ways to mark an import as lazy:
import functools
lazy import re
@functools.cache
def regex() -> re.Pattern[str]:
return re.compile(...)
from __future__ import annotations
__lazy_modules__ = ["re"]
import functools
import re
@functools.cache
def regex() -> re.Pattern[str]:
return re.compile(...)
Now, re will not be imported until you call the regex() function. If you
never do, it’s never imported. Accessing the special object materializes it,
except in type annotations. This makes things like command line tools faster,
and enables common patterns (like importing everything into a top level module)
to not have an import time drawback. Given that uv does not compile pyc files
by default when installing, this can have an even bigger first-run impact for
uses like uvx. It also support lazy from X import Y syntax.
There’s also a second set of uses; this can help fix circular imports, typing-only imports, and optional dependencies. This only works if you can’t disable lazy imports, so a future beta might remove the option to disable all lazy imports (there is one currently).
This does have a drawback: it moves the import errors to first use. For stdlib,
that’s not a problem, but if you are importing a dependency that might be
missing, you can sometimes use importlib.util.find_spec("module") to check to
see if something is installed without importing it. Due to the way the import
system works, this will import parent modules, and there’s a small (shared)
cost.
If you need help writing __lazy_modules__, my flake8-lazy plugin helps with
that.
Sampling Profiler
The new sampling profiler, tachyon, is awesome. I used it in
my performance of packaging post. The existing cProfile
has become profiling.tracing (back-compat name remains available, though). The
new one is profiling.sampling. Tracing profilers tell you everything that
runs, at the cost of changing the performance of your application. Sampling
profilers just check to see what the state is at a frequency (and Tachyon
supports up to 1MHz!), keeping the performance profile consistent. Having the
fastest sampling profiler for Python ever created built in is amazing!
This also supports modern features, like:
- Attachment to running processes (like other 3.14 tools)
- Dumping the state of a stuck process
- Async and thread aware
- Multiple modes, like CPU, wall time, GIL-holding time, exception handling time
- 5 output modes, including a self-contained HTML flamegraph using D3.js!
- Live monitoring mode
- Opcode profiling, including the specializations from the adaptive interpreter
This should be one of the first things you try.
python -m profiling.sampling run ... is all you need to get started. On macOS,
it does require sudo, like other sampling profilers.
Also note that CPython is now built with frame pointers enabled, so the profiler (and debugs and other tools) can handle native stack unwinding quickly, which helps. Note that third-party distributions might need updating.
New builtins
There are two new builtins! The first is frozendict, which does what it
says. It’s a mirror of frozenset, but for dict! It is not mutable and can be
used where immutable values are accepted (like as a dict key). The one thing to
keep in mind: like frozenset and set, it’s not a subclass of dict, so
isinstance(D, dict) will be false if D is a frozendict. Always use
collections.abc.Mapping, pattern matching, or duck typing for such a check and
you’ll be fine.
The second builtin is sentinel, and it’s great. There are a few sentinel
values in Python already, like None and Ellipsis. However, if you need your
own, there’s not a really standard way to do it. Here’s what it looks like:
MISSING = sentinel("MISSING")
def is_missing(item: int | None | MISSING = MISSING) -> bool:
return item is MISSING
MISSING = object()
def is_missing(item: Any = MISSING) -> bool:
return item is MISSING
Unlike the older workarounds, this:
- Has a nice repr (
MISSINGinstead of<object object at 0x000002825DF09650>) - Works correctly when pickled or copied (model or class level sentinels)
- Supports static typing
A backport (with slightly different semantics) is available in
typing_extensions.
New syntax
There’s a small piece of new syntax: unpacking in comprehensions. This is something I think most people have tried at least once; and it works now!
Let’s say you have a nested list:
lists = [[1, 2], [3, 4], [5]]
And you want to flatten it. Knowing how * works everywhere else in Python,
this seems like it would work:
[*lst for lst in lists]
But you ended up having to rewrite it as
[item for lst in lists for item in lst] (if the order is hard to remember,
just format it like a normal nested for loop but without colons and inner
contents above it). Well, now the above expression just works! (generator, list,
set, and dict comprehensions all supported, also async variations).
Errors and colors
python --help is now in color. Unraisable exceptions are colored too. Tab
completion in the REPL is now colored by object kind. sqlite3, ast,
difflib, tokenize, http.server, and timeit now support color. pdb uses
the new REPL now.
Error messages are now better. Nested attributes are now included in the
suggestions; if you access container.area and there’s a
container.inner.area, that will be suggested, for example. And even more
magical; if you try to use a method name from Java, JavaScript, Ruby, or C#,
then Python will suggest the Python equivalent! list.push will recommend
list.append, or str.toUpperCase will recommend str.upper. If you try to
use a mutable method on a immutable object, the error message will suggest using
the mutable counterpart (currently just a few methods covered). del a.b now
also suggests on failure.
Typing changes
The new TypeForm describes the special typing expression’s types. If you write
code that processes types (which I do a lot, for declarative dataclass based
structure processing), this is really handy to typecheck that code.
TypedDict now has closed (no extra keys allowed) and extra_items (type for
arbitrary extra keys).
Other changes:
slicesupports subscription.@typing.disjoint_base, an advanced feature but should help type checkers better reflect runtime.TypeVarTupletakes the keyword arguments thatTypeVar/ParamSpecalready does.
Performance
The JIT compiler is getting faster. The tail-calling interpreter is faster too, so JIT is about 8-9% faster, and 12-13% faster on Apple Silicon (15% slower to 100% faster, depending on what you are doing). If it weren’t for the tail-calling interpreter, maybe we’d already be a the threshold for enabling the JIT by default. It should be available on official and uv distributions but still is opt-in.
Windows now can be built with the tail calling interpreter (VS 2026+ required as the MSVC team helped make this possible). This gives a 14-40% speedup on Windows over the old interpreter. Official binaries are built this way.
Base64 encoding/decoding 2-3x faster, and base32, Ascii85, base85, and Z85 are 2 orders of magnitude faster. Class creation is a big faster with shared descriptors.
The lazy imports feature above is also a performance feature. Frame pointers also can be one.
Other features
TOML support is now TOML 1.1 This is backward compatible, but keep in mind that something that parses in 3.15 might not parse in 3.11-3.14. Changes include trailing command and newlines in inline tables (fantastic!), some new notation for codepoints, and seconds are optional.
math.integeradded for integer-argument math functions.TaskGroup.cancelcan cancel running task groups.- Complex numbers and half-floats are now supported in
array - Argparses’s
suggest_on_error(from 3.14) is nowTrueby default. array_hookoption forjson.load(prefect withobject_pairs_hookandtuple/frozendict!)- More math functions, like
math.isnormal. - t-string support for
pprint(and better pprint defaults). re.prefixmatchadded as better spelling forre.match.
Removals and deprecations
There are quite a few removals and new deprecations. If you’ve been keeping warnings as errors turned on in your test suite and running against 3.14, you should be ready (though you might see some new ones). I don’t think most code will run into the removals, you might see a new deprecation warning though.
Using NamedTuple as a function (undocumented) is gone. ByteString no longer
shows up in __all__ (deprecated in 3.12, removal coming in 3.17). Several
deprecated arguments and methods have been cleaned up.
Lots of standard library modules actually had a __version__, version, or
VERSION, those are deprecated; just check sys.version_info. The loop policy
for asyncio is deprecated.
As mentioned above, re.match is deprecated in favor of re.prefixmatch, but
it’s a soft deprecation, meaning there are no plans to remove it. Just update
when you drop 3.14.
Other Developer changes
There are some big developer changes this round. I’ve already covered the new profiler. But there are more big ones!
UTF-8 is now the default. This has been in the works for a long time, with
PYTHONWARNDEFAULTENCODING warning if you didn’t request an encoding
explicitly, but now it’s the default. I think this will be a huge boon for new
Python developers, as I’ve often seen errors opening files in the native
encoding by mistake. Hopefully you’ve been enabling that warning and then
explicitly specifying the encoding. Note that the “locale” encoding as an
explicit option was added in 3.10; use that if you do need native encoding.
New package start up configuration. Python has *.pth files that run
whenever you startup Python (unless you pass -S). Those path files support two
unrelated things, though: they can contain path entries and they contain
import <...> lines that import (and therefore run) arbitrary code. This is how
editable installs are implemented, by the way! Now Python has a new file type,
*.start, that takes over the import feature of *.pth files. It also now
calls an entry point, keeping your importer files from having to rely on a
import-time side effect. In 3.15 and 3.16, if you have matching X.start and
X.pth files, the import lines will be ignored in the X.pth file. Eventually
*.pth files will only support paths.
Stable ABI for free-threaded builds. There’s now a stable ABI for
free-threaded Python! It has a t suffix (abi3t). This means you can build a
3.15 free-threaded wheel that will work on future versions of Python. Tools like
scikit-build-core are already being updated to support it.
Other changes:
- Protecting the C API from interpreter finalization with new guards and views and APIs.
PYTHONWARNINGS/-Wnow supports regular expressions if it starts and ends with a forward slash.bytearray.take_bytesadded, is faster thanbytes(bytearray)- Various methods related to compiling/parsing code now can take module name
- Back to the old garbage collector (also backported to 3.14.5!)
- Waiting for a subprocess to terminate is more efficient on some platforms.
sys.abi_infoadded.- Lots of new C-API, including
PyWritesBuffer, a mechanism to write an binary buffer,PySlot, and more.
Updating to 3.15
So far, updates have been really easy.
Final Words
The easiest and fastest way to get any Python is using uv:
uv python install 3.15
# Or 3.15t
This is also one of the best optimized versions of Python, generally faster than homebrew, etc.
If you are using GitHub Actions:
- uses: actions/setup-python@v5
with:
python-version: "3.15"
allow-prereleases: true
This works in a matrix, etc. too. If you want to try out free-threaded Python,
that’s either "3.15t" or enable the free-threaded option.
Sources and other links
AI usage disclaimer: every letter was typed by me. AI helped review a draft of the post: Qwen3.5:397 (which complained about 3.15 not existinting and called my post satire…) and Kimi-K2.6 (I loaded the release notes into the context to avoid any complaints this time!), both using OpenCode, were both helpful and found a few issues.