Back to articles
A Python language logo sticker held by a woman

What's new in Python 3.15?

Introduction

There's no doubt the newest version of Python is approaching fast — version 3.15, scheduled for October 1st, brings a handful of interesting changes. Below you'll find my write-up: the things I considered most important. At the time of writing, the second release candidate is already available for testing the changes.

Table of contents

GIL

This is probably something every Python developer knows well: Python's "Global Interpreter Lock" mechanism blocks Python code from running on multiple threads at the same time. I remember this undeniable rule very well myself, since it was one of the questions I got asked at my master's thesis defense.

Up through version 3.12, the statement above was true. Now, however, Python's developers are doing away with this mechanism — starting with version 3.13, and continuing with each subsequent release, new interpreter variants called free-threaded have been introduced, which remove this overhead and allow threads to be used freely.

This mechanism isn't a simple switch like unlock_full_thread_performance = true, but rather a full-blown software migration — but that's material for a separate article.

In version 3.15 this mechanism keeps being improved — the most important change is the introduction of a new C-language API called abi3t (as a successor to abi3) that supports multithreading. On top of that, encapsulation has been increased (among other things, PyObject is no longer directly part of the instance struct in C), official installers with free-threading support have been prepared, and frozendict was introduced, which we'll get to below.

Sentinel

PEP-661, landing together with Python 3.15, introduces a new so-called sentinel value. The simplest example of a sentinel value already present in Python is None — the new mechanism is meant to let you create your own "empty" values, used in cases where you need two different empty values that mean two different things. Sentinel values also come with a much friendlier appearance in logs. Overall, it's a nice improvement — you can watch more about it in an interesting piece prepared by BugBytes.

Lazy Imports

PEP-810 is probably one of the better, more tangible changes in the latest release. I don't know a Python developer who's never run into an import cycle — this change can make it easier to avoid such cycles, by ensuring modules are only loaded once they're actually needed. It will require adjusting your code, but I think it's worth it. On top of that, tests and cold starts in web applications will speed up, as will CLI tools — though that's a minor perk compared to the main benefit, and besides, isn't build time devops's problem anyway?

Gif from The IT Crowd, showing Moss suggesting turning the computer off and on again as the solution to the problem

Getting back to the topic seriously — this comes with a few pitfalls that will require reworking some code. One of them is that using a single function from a lazily imported module still means the whole module gets imported, so if you're not already doing it, it's worth switching to importing only what you actually need.

If you're not yet familiar with this topic, I recommend the video on the anthony writes code channel as a companion to the PEP. Fun fact: Anthony was one of the people who worked on improving the error message for circular imports.

Static Typing

PEP-814: frozendict

It probably won't surprise anyone that frozendict is, in practice, a dict (though it doesn't inherit from it!), just immutable — the counterpart to the already-implemented frozenset. Personally, I don't see any huge advantages to this new type, aside from getting rid of a few ugly hacks and being able to cache a function that takes a dictionary.

A few of the more interesting use-case examples for frozendict that I managed to find:

# Using frozendict as a key, in this case for a fairly complex RBAC setup
user_permissions = {
    frozendict(role="admin", region="US"): ["read", "write", "delete"],
    frozendict(role="viewer", region="EU"): ["read"],
}
# Example of a function taking a dict, which in 3.15 can now be cached
from functools import cache

@cache
def get_expensive_report(report_config: frozendict):
    return query_database(report_config)

PEP-728: TypedDict

A step toward better static typing — this change lets you create your own dictionary types, either fully closed or with restricted extra keys. It's best shown with an example:

from typing import TypedDict

# A TOTALLY CLOSED dict (no extra keys allowed!)
class StrictMovie(TypedDict, closed=True):
    title: str
    year: int

# A dict that allows extra keys, but ONLY of type str (e.g. tags)
class FlexibleMovie(TypedDict, extra_items=str):
    title: str
    year: int

# --- WHAT DOES THIS LOOK LIKE IN PRACTICE? ---

# ERROR for StrictMovie: "director" isn't defined, and the dict is closed
m1: StrictMovie = {"title": "Inception", "year": 2010, "director": "Nolan"}

# OK for FlexibleMovie: "director" is a str, so extra_items=str accepts it
m2: FlexibleMovie = {"title": "Inception", "year": 2010, "director": "Nolan"}

# ERROR for FlexibleMovie: "rating" is an int (9), and extra fields may only be str!
m3: FlexibleMovie = {"title": "Inception", "year": 2010, "rating": 9}

PEP-747 - TypeForm

This is a feature that will matter more to library authors in Python — the new TypeForm annotation lets functions accept a type annotation as a value:

from typing import TypeForm

# The function accepts a dynamic type form (e.g. list[int])
def trycast[T](target_type: TypeForm[T], value: object) -> T | None:
    # Note: in practice this needs more than a plain isinstance() check —
    # isinstance() alone can't handle parameterized generics like list[int];
    # that's why libraries such as `trycast` implement their own,
    # purpose-built runtime matching logic.
    if is_assignable(value, target_type):
        return value
    return None

# --- USAGE ---
# A type checker (e.g. pyright) now knows PERFECTLY that the result is list[int] | None
result = trycast(list[int], [1, 2, 3])

PEP-800 - @disjoint_base

This change is probably better classified as a bugfix to static typing. Put simply: in version 3.14 you could create a monster class that was simultaneously an integer and a string:

class MyAbomination(int, str): pass

A Disney monster

In version 3.15 — you no longer can.

Smaller changes

PEP-798 - Nicer list unpacking

Instead of reaching for itertools.chain or functools.reduce, in Python 3.15 you'll be able to use the notation below to unpack a list of lists into a single list:

[*it for it in its]  # a list made of all the elements from the lists in its
{*it for it in its}  # a set made of all the elements from the lists in its
{**d for d in dicts} # a dict combining all the dicts in dicts
(*it for it in its)  # a generator yielding all the elements from the lists in its

The rest

What I'm personally looking forward to most

Personally, I can't wait for full-blown multithreading to be added. I can't shake the idea of writing a simple game engine using Python — fitting for a GameDev graduate — and even though it probably won't be as performant as engines written in C/C++, the edge it gets from a lightning-fast feedback loop will be huge.

The second feature I'm looking forward to is lazy imports — I hate the if TYPE_CHECKING: syntax and try to avoid it wherever I can, since it's not exactly pretty or clean.

Summary

Big changes are brewing in Python — versions 3.13/3.14 were the seed of this, while 3.15 is already breaking ground. Still, you'll need to arm yourself with patience before these changes make it into real, business-critical use — that could take a few years yet.

Sources