Python 3.15

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.


Posts in the Python Series2→3 3.7 3.8 3.9 3.10 3.11 3.12 3.13 3.14 3.15

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

Tachyon profiler logo

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 (MISSING instead 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:

  • slice supports subscription.
  • @typing.disjoint_base, an advanced feature but should help type checkers better reflect runtime.
  • TypeVarTuple takes the keyword arguments that TypeVar/ParamSpec already 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.integer added for integer-argument math functions.
  • TaskGroup.cancel can cancel running task groups.
  • Complex numbers and half-floats are now supported in array
  • Argparses’s suggest_on_error (from 3.14) is now True by default.
  • array_hook option for json.load (prefect with object_pairs_hook and tuple/frozendict!)
  • More math functions, like math.isnormal.
  • t-string support for pprint (and better pprint defaults).
  • re.prefixmatch added as better spelling for re.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/-W now supports regular expressions if it starts and ends with a forward slash.
  • bytearray.take_bytes added, is faster than bytes(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_info added.
  • 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.


Posts in the Python Series2→3 3.7 3.8 3.9 3.10 3.11 3.12 3.13 3.14 3.15