Skip to content
← All articles

PEP 696: default values for type parameters

Repo[T = str] is the difference between a generic API people use and one they route around. The ordering rules, the TypeVarTuple exception, typing.NoDefault, and what it does to --disallow-any-generics.

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.