On this page
Description
Python 3.12 and 3.13 tidied up a lot of everyday code: generics without TypeVar boilerplate, f-strings that behave like normal code, @override, itertools.batched, copy.replace and @deprecated. They also removed a set of old standard-library modules, and laid the groundwork for running without the GIL. Some of these are new syntax, and that matters as much as what they do: a file using new syntax can't even be read by an older Python.
Here's the kind of bug we mean. A library adds a convenience in a minor release:
log.info(f"loaded {", ".join(names)}")
It works on the maintainer's Python 3.12 - and every user on 3.11 gets a SyntaxError when the module is imported, before a single function runs. Python 3.12 relaxed f-string grammar (PEP 701) so the expression can reuse the enclosing quotes; 3.11's parser can't read the file at all.
We'll go from simple to more technical:
- Type parameter syntax:
def first[T],class Box[T]andtypealiases. - f-strings,
@overrideandbatchedin 3.12. - What 3.13 added: a new REPL,
copy.replace,@deprecatedand more. - What 3.12 and 3.13 removed, and how to upgrade safely.
Type parameter syntax (3.12)
Before 3.12, a generic function needed a module-level type variable:
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T: ...
PEP 695 puts the type parameters in the definition:
def first[T](items: list[T]) -> T:
return items[0]
class Box[T]:
def __init__(self, item: T) -> None:
self.item = item
def largest[T: (int, float)](xs: list[T]) -> T: ... # constrained
def save[T: Model](obj: T) -> T: ... # bounded
type Pair = tuple[int, int]
type Tree = dict[str, Tree] | None # refers to itself - no quotes needed
- The parameters are scoped to the function or class (
first.__type_params__is(T,)), so there's no shared globalTand no naming ceremony. typestatements create aTypeAliasTypewhose value is evaluated lazily - on first access toPair.__value__- which is why recursive aliases and forward references work without quotes.- Like all annotations, these are for type checkers; nothing is enforced at run time.
f-strings, @override and batched (3.12)
- f-strings are parsed like normal code (PEP 701): expressions may reuse the enclosing quote character (
f"{", ".join(names)}"printedAda, Bo), nest f-strings, contain backslashes and comments, and span lines. Convenient - but it's new syntax, so a file using it doesn't parse on 3.11 or older. @typing.overridemarks a method as overriding a base-class method. A type checker reports an error when the base method doesn't exist (say, after it was renamed), catching silent "overrides" of nothing. At run time it only sets__override__ = True.itertools.batched(iterable, n)yields tuples of up tonitems:batched(range(7), 3)gives(0, 1, 2), (3, 4, 5), (6,). In 3.13,strict=TrueraisesValueError: batched(): incomplete batchif the last one is short.- 3.12 also gave each subinterpreter its own GIL (PEP 684) and made
None, small ints and other singletons immortal (PEP 683) - groundwork for free-threading.
Python 3.13
- A new interactive REPL with colors, multi-line editing and history, and paste mode.
- Experimental free-threaded build (
python3.13t, PEP 703) and an experimental JIT (PEP 744), both off by default - see Free-Threaded Python and Subinterpreters. copy.replace(obj, **changes)copies an immutable object with some fields changed - frozen dataclasses, named tuples,datetime-copy.replace(config, retries=5)leaves the original untouched.@warnings.deprecated("use fetch_v2")(PEP 702) emits aDeprecationWarningwhen the function is called and makes type checkers flag every call.locals()semantics (PEP 667) are defined precisely in functions: it returns a snapshot, so writing to it never changes local variables.
What's gone
PEP 594 retired old, unmaintained standard-library modules ("dead batteries"), after deprecation in 3.11:
| Removed in | Modules |
|---|---|
| 3.12 | distutils (use setuptools), imp (use importlib), asyncore, asynchat, smtpd |
| 3.13 | cgi, cgitb, telnetlib, crypt, nntplib, pipes, sndhdr, imghdr, audioop, mailcap, msilib, nis, ossaudiodev, spwd, sunau, uu, xdrlib, aifc, chunk |
Code (or an old dependency) that imports one fails with ModuleNotFoundError: No module named 'cgi' on upgrade - every one in the table failed that way on 3.14. Run your test suite with -W error::DeprecationWarning on the release before an upgrade: removals are always announced as deprecation warnings first.
Which tool for which job?
| You want to | Use |
|---|---|
| A generic function or class | def first[T](...) / class Box[T]: (3.12+) |
| The same on older Pythons | T = TypeVar("T"), or typing_extensions |
| A named type, possibly recursive | type Tree = dict[str, Tree] | None |
| Catch "overrides" of a renamed base method | @typing.override + a type checker |
| Process items in chunks | itertools.batched(items, n) |
| Copy an immutable object with a change | copy.replace(obj, field=value) (3.13) |
| Retire a function politely | @warnings.deprecated("use ...") (3.13) |
| Find removals before an upgrade | run tests with -W error::DeprecationWarning |
Common mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
PEP 701 f-strings or def f[T] in a library that supports 3.11 | SyntaxError on import for those users | wait until you drop 3.11 |
Expecting @override to check at run time | nothing happens | run a type checker in CI |
Mixing an old TypeVar and new syntax for one parameter | confusing errors from the checker | pick one style per definition |
| Upgrading with deprecation warnings ignored | ModuleNotFoundError for removed modules | fix warnings on the release before |
batched without strict=True where batches must be full | a short last batch slips through | strict=True (3.13) |
Check yourself
Try to answer before opening each one - recalling beats re-reading.
1. Why can't a library that supports Python 3.11 use f"{", ".join(names)}"?
Show answer
Reusing the enclosing quotes inside an f-string is 3.12 syntax (PEP 701). On 3.11 the whole module fails to parse with a SyntaxError at import.
2. Why does type Tree = dict[str, Tree] | None work without quotes?
Show answer
A type statement's value is evaluated lazily, when the alias is first used, by which time Tree exists.
3. What does @override do at run time?
Show answer
Only sets __override__ = True on the method. Its value is in type checkers, which flag an @override method whose base method doesn't exist.
Key takeaways
def f[T]andtype X = ...replaceTypeVarboilerplate; aliases are lazy.- PEP 701 f-strings and type-parameter syntax raise your minimum Python to 3.12.
@override,batched,copy.replace,@deprecated- small, useful, checked by tools.- 3.12 and 3.13 removed the dead batteries; heed deprecation warnings before upgrading.