Skip to content

What's New in Python 3.12

3.12

Python 3.12 was released in October 2023. The headliners are much more flexible f-strings, the new type statement for type aliases, @typing.override, and the sys.monitoring API for building profilers.


Summary

Category Change
Language F-string improvements (PEP 701)
Language type statement for type aliases (PEP 695)
Typing @typing.override decorator (PEP 698)
CPython sys.monitoring API (PEP 669)
Standard Library pathlib.Path.walk()
Standard Library itertools.batched()
Performance ~5% faster than 3.11 on average

PEP 701 — Better F-Strings

Before 3.12, f-strings had a bunch of annoying restrictions: you couldn't reuse the same quote type inside the expression, you couldn't have backslashes inside {}, and multi-line f-strings were painful.

3.12 fixes all of that:

# Previously invalid — quote type collision
name = "world"
# msg = f"Hello {"world"}"  # SyntaxError before 3.12

# Now valid in 3.12!
msg = f"Hello {"world"}"
print(msg)  # Hello world

# Backslashes inside f-string expressions — now valid
items = ["a", "b", "c"]
print(f"Joined: {'\n'.join(items)}")

# Nested f-strings — now properly supported
x = 42
print(f"{'positive' if x > 0 else 'negative'}")

# Multi-line f-string expressions
result = f"""
Name: {"Alice"}
Score: {
    100 +
    200 +
    300
}
"""

PEP 695 — type Statement for Type Aliases

Before 3.12, creating a type alias was a bit awkward:

# Old way (3.11 and earlier)
from typing import TypeAlias
Vector: TypeAlias = list[float]

Python 3.12 introduces the type keyword:

# New in 3.12 — clean and unambiguous
type Vector = list[float]
type Matrix[T] = list[list[T]]  # Generic type alias

# Use it
def scale(v: Vector, factor: float) -> Vector:
    return [x * factor for x in v]

It also works for generic functions and classes:

# Generic function — no need to import TypeVar
def first[T](lst: list[T]) -> T:
    return lst[0]

# Generic class
class Stack[T]:
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T:
        return self._items.pop()

@typing.override

Marks a method as intentionally overriding a parent class method. Type checkers will error if the parent class doesn't have a matching method — catching silent breakage when you rename a base class method.

from typing import override

class Base:
    def process(self) -> None:
        print("base")

class Child(Base):
    @override
    def process(self) -> None:  # OK — Base.process exists
        print("child")

class Broken(Base):
    @override
    def prcess(self) -> None:  # Type checker error! Typo — no Base.prcess
        print("oops")

sys.monitoring — PEP 669

A low-overhead monitoring API for profilers and debuggers. Instead of sys.settrace() (which has ~10x overhead), sys.monitoring lets you subscribe to specific events at minimal cost.

import sys

# Enable function call monitoring
sys.monitoring.set_events(
    sys.monitoring.DEBUGGER_ID,
    sys.monitoring.events.CALL
)

def on_call(code, instruction_offset, callable, arg0):
    print(f"Called: {callable.__name__}")

sys.monitoring.register_callback(
    sys.monitoring.DEBUGGER_ID,
    sys.monitoring.events.CALL,
    on_call
)

New Standard Library Features

pathlib.Path.walk()

Path objects finally have a walk() method, equivalent to os.walk():

from pathlib import Path

for dirpath, dirnames, filenames in Path(".").walk():
    for f in filenames:
        print(dirpath / f)

itertools.batched()

Split an iterable into fixed-size chunks — a common need that previously required a recipe:

from itertools import batched

data = range(10)
for chunk in batched(data, 3):
    print(list(chunk))
# [0, 1, 2]
# [3, 4, 5]
# [6, 7, 8]
# [9]  ← last chunk may be smaller