Python 3.13, via PEP 696, lets a type parameter carry a default:
class Repo[T = str]:
def get(self, key: str) -> T: ...
Repo() now means Repo[str]. Repo[int]() still means Repo[int]. The
generic form and the common form are the same class, and nobody has to write
the parameter unless they are deviating from it.
That sounds cosmetic. It is not, and the reason is social rather than
technical: a generic API that forces every user to spell out a parameter they
never vary gets routed around. Someone writes a non-generic wrapper, or a
module-level alias, or — most often — just annotates the call site with the
concrete type and reaches for # type: ignore when the shapes disagree. A
default is how you make the ninety-percent case free while keeping the
ten-percent case expressible.
The syntax, in all three positions
# a function's parameter
def load[T = bytes](path: str) -> T: ...
# a class's parameter
class Cache[K, V = object]:
def put(self, key: K, value: V) -> None: ...
# an alias's parameter
type Pair[T = int] = tuple[T, T]
And in the old TypeVar spelling, for libraries that still support 3.12 via
typing_extensions:
from typing import TypeVar
T = TypeVar("T", default=str)
The ordering rules
They mirror function argument defaults, which is the right mental model: once you have supplied a default, everything after it needs one too.
class Ok[T, U = int, V = str]: ... # fine
class Bad[T = int, U]: ... # error: non-default after default
Partial specialisation works the way you would hope: Ok[bool] is
Ok[bool, int, str], Ok[bool, float] is Ok[bool, float, str].
There is one rule with no function-argument analogue, and it is the one people
trip over: a TypeVar immediately following a TypeVarTuple may not have a
default.
class Shaped[*Ts, T = int]: ... # error
💡Why is that restriction necessary? Work out what Shaped[int, str] would have to mean.
click to reveal
Because a TypeVarTuple is variadic, there is no way to tell where it stops
and the following parameter starts.
Given class Shaped[*Ts, T = int], what is Shaped[int, str]? It could be
Ts = (int, str) with T taking its default int. Or it could be
Ts = (int,) with T = str. Both readings are consistent with the
declaration, and nothing in the syntax disambiguates them.
Without a default, the ambiguity does not arise: T is mandatory, so the last
supplied argument is always T and everything before it is Ts. The greedy
reading is forced. Adding a default removes the anchor.
Note the restriction is specifically about the parameter immediately
following the TypeVarTuple. class Shaped[T = int, *Ts] is fine — the
defaulted parameter is before the variadic, where the position is
unambiguous.
typing.NoDefault
You need a way to ask “does this parameter have a default?” and to
distinguish “no default” from “the default is None“. PEP 696 adds a sentinel:
from typing import NoDefault
T = TypeVar("T", default=str)
U = TypeVar("U")
T.has_default() # True
T.__default__ # <class 'str'>
U.has_default() # False
U.__default__ # typing.NoDefault
NoDefault is a singleton in typing, and it is the answer to the obvious
design question: None is a perfectly good default type (T = None is a
legal, if odd, declaration), so it cannot double as the absence marker.
This matters if you write anything that introspects generics — a serialiser,
a DI container, a plugin registry that instantiates user-supplied generic
classes. __default__ is part of the runtime API, not just the type-checker’s
bookkeeping.
💡A library exposes class Session[T = dict[str, str]]. A user subclasses it as class MySession(Session). What is T in the subclass, and what would it have been before PEP 696?
click to reveal
In both cases the subclass is implicitly parameterised, but the parameter resolves differently.
Before PEP 696, class MySession(Session) — with Session unsubscripted —
meant Session[Any]. That is the long-standing rule for an unparameterised
generic base: the parameter silently becomes Any, and every method that
returns T returns Any from then on. It is one of the quietest ways a typed
codebase loses coverage, which is exactly why --disallow-any-generics exists.
With a default, class MySession(Session) means Session[dict[str, str]].
The author’s intended common case is what you get, and it is a real type
rather than an escape hatch. If you want the old behaviour you now have to ask
for it explicitly with Session[Any] — which is the right way round.
The interaction with --disallow-any-generics
This is the subtle consequence, and it is worth stating plainly because it changes what your strict config does.
--disallow-any-generics (part of --strict) rejects a bare generic
annotation: def f(x: list) -> None is an error, because the omitted
parameter would silently become Any.
It does not force parameterisation of a class whose parameters all have
defaults. def f(x: Repo) -> None is accepted when Repo is declared
class Repo[T = str], because the omitted parameter is not Any — it is
str. Nothing is being laundered, so there is nothing to complain about.
That is correct behaviour, but notice what it means for a library author: once you add a default to a public generic, every downstream codebase that was being forced to spell the parameter out stops being forced. That is usually the point. It is occasionally a regression — if the parameter is one callers genuinely should think about, a default is an invitation not to.
💡You maintain a library with class Result(Generic[T, E]), used across dozens of codebases. You want to add E = Exception as a default. Is that a breaking change?
click to reveal
Not for existing call sites — every one of them already spells both parameters, and their meaning is unchanged. Adding a default is backward-compatible in that direction.
It is potentially breaking in two less obvious ways.
First, the minimum Python version. PEP 696 syntax is 3.13. If your
requires-python is >=3.10, you must express the default through
typing_extensions.TypeVar(default=...) instead, and take on the dependency.
The PEP 695 class syntax is a hard SyntaxError on older interpreters — it
cannot be feature-detected at runtime, because the file fails to parse before
any of your code runs.
Second, anything downstream that introspects __type_params__ or
__default__. Code that assumed no parameter had a default, and used
TypeVar.__default__ naively, now gets a type object where it previously got
an AttributeError (pre-3.13) or NoDefault. That is a narrow blast radius,
but serialisation libraries live exactly there.
The version question is the real one. In 2026 a library declaring
requires-python = ">=3.12" is defensible; one declaring >=3.13 to get
parameter defaults is not, and typing_extensions is the answer.
When to add a default, and when not to
Add one when there is an obvious dominant instantiation and the generic form
is the rarity. Repo[T = str] for a key-value store whose values are usually
strings. Cache[K, V = object]. Result[T, E = Exception].
Do not add one when the parameter is the whole point of the type. A
Serializer[T] with a default is a Serializer that quietly serialises the
wrong thing when somebody forgets, and the forgetting is now invisible because
the checker stopped asking. The test is simple: if a wrong parameter would be
a bug rather than an inconvenience, make the caller state it.
This is the same judgment as mutable default arguments and implicit conversions. A default is a claim that being wrong here is cheap. Make sure it is.