TypeVar Defaults with PEP 696
TL;DR — PEP 696, new in Python 3.13, lets a type parameter carry a default: T = TypeVar("T", default=int), or in PEP 695 syntax class Box[T = int]. When a caller leaves the parameter off, the checker substitutes the default instead of falling back to Unknown/Any. Defaulted parameters must come after non-defaulted ones, and typing_extensions backports the feature to older runtimes.
Before PEP 696, writing Box without a type argument left the parameter unresolved — pyright reported Unknown, mypy silently used Any, and you lost type safety exactly where an ergonomic default would have helped. Defaults close that gap: a generic can declare a sensible fallback so the bare, unparameterized form is still precisely typed. This page, part of Advanced Typing Patterns & Generics and the Generics and TypeVar cluster, covers the syntax, the ordering rules, and analyzer support.
The syntax, both spellings
There are two equivalent ways to declare a default. The classic TypeVar object takes a default= keyword; the PEP 695 inline form uses = Type after the parameter name. Both compile to the same declaration — pyright and mypy treat T = TypeVar("T", default=int) and the T = int inside class Box[T = int] as one and the same defaulted parameter, so the choice is purely stylistic. Reach for the explicit TypeVar object when you want to share one parameter across several classes or combine the default with bound=/constraints in the same constructor call; reach for the inline spelling when you are already writing PEP 695 type-parameter syntax.
# Python 3.13+, checked with pyright 1.1.370
from typing import TypeVar, Generic
T = TypeVar("T", default=int)
class Box(Generic[T]):
def __init__(self, value: T) -> None:
self.value = value
reveal_type(Box(3).value) # int
reveal_type(Box("x").value) # str — explicit argument still wins
empty: Box = Box(3)
reveal_type(empty.value) # int — default fills the bare Box
# Python 3.13+, checked with pyright 1.1.370 — PEP 695 inline form
class Cache[K = str, V = int]:
def __init__(self) -> None:
self._data: dict[K, V] = {}
reveal_type(Cache()) # Cache[str, int] — both defaults apply
reveal_type(Cache[bytes, float]()) # Cache[bytes, float] — explicit wins
An explicit type argument always overrides the default; the default only supplies the parameter when the caller omits it. This is why Box (bare) is now Box[int] rather than Box[Unknown].
A default is an ordinary type expression, so it is not limited to a concrete class. It can be None, a fully parameterized generic such as list[str], a union like int | str, a Literal, or a forward reference written as a string when the target is defined later in the file. Defaults are also consumed in more places than a class: a PEP 695 generic type alias may carry one — type Pair[T = int] = tuple[T, T] resolves a bare Pair to tuple[int, int] — and a generic function falls back to the default for any type parameter it cannot solve from the call arguments (typically a TypeVar that appears only in the return type). Where the parameter is inferable from an argument, that inference always wins over the default, exactly as an explicit argument does on a class.
The two spellings are not perfectly interchangeable in capability. The TypeVar object form is the only one that lets you combine a default with the other constructor keywords — bound=, a list of constraints, or the legacy covariant=/contravariant= markers — and it lets you bind the resulting object to a name you reuse across several unrelated classes and functions. The inline [T = int] form has no place to express those extra keywords (variance is inferred automatically under PEP 695, and a bound is written [T: SomeBase]), but it wins on locality: the parameter is scoped to the single class, alias, or function that declares it, so there is no module-level TypeVar to accidentally share or leak. One further behavioural note: in the inline form the default expression is evaluated lazily, which is what allows class Node[T = "Node"]-style self-references and forward references to resolve without a quoted string in modern syntax.
Ordering rules
Defaults follow the same rule as function parameter defaults: once a parameter has a default, every parameter to its right must have one too. A defaulted parameter cannot precede a non-defaulted one, because the checker could not tell which argument you meant to omit.
# Python 3.13+, checked with pyright 1.1.370
class Ok[T, U = int]: ... # fine — default is last
class Bad[T = int, U]: ...
# pyright: reportGeneralTypeIssues — non-default type parameter follows a default
# mypy: [misc] — TypeVar with a default cannot precede one without
A default may also reference an earlier parameter: class Pair[T, U = T] makes U fall back to whatever T resolved to. The referenced parameter must appear earlier in the list, mirroring the left-to-right ordering constraint — class Pair[T = U, U = int] is rejected because U is not yet in scope where T’s default is evaluated. When both are omitted, Pair resolves to Pair[int, int]; when only the first is supplied, Pair[str] resolves to Pair[str, str], because U inherits T.
The default must also be consistent with the parameter’s own constraints. A TypeVar declared with bound= requires its default to be a subtype of that bound, and a constrained TypeVar requires the default to be one of the listed constraints. TypeVar("T", bound=str, default=int) is contradictory and is reported ([type-var] in mypy, reportGeneralTypeIssues in pyright); TypeVar("T", int, str, default=bytes) fails for the same reason because bytes is not among the constraints. Mixing kinds adds one more wrinkle: because a TypeVarTuple matches a variable number of positions, a plain TypeVar that follows an unpacked *Ts in the parameter list behaves like a keyword-only slot, so it needs a default of its own once *Ts has one — a constraint pyright and mypy enforce when you write variadic generics such as class Row[*Ts, T = int].
At runtime none of these positions matter for execution, but Python 3.13 does expose them for introspection: iterating Pair.__type_params__ yields the TypeVar objects in declaration order, each answering .has_default() and .__default__ so tooling can reconstruct the ordering rule programmatically.
Box(3) constructs the same object whether or not T has a default, and Python performs no substitution — the default exists purely to guide the static checker when a type argument is omitted. You can read the declared default reflectively via T.__default__ and test for one with T.has_default(), but neither changes how the code executes.
Analyzer support and the backport
Pyright implemented PEP 696 ahead of the 3.13 release and handles both spellings, defaults referencing earlier parameters, and the ordering diagnostics. mypy added support over its 1.x line; confirm you are on a recent release (1.12+) before relying on defaults, as earlier versions rejected the default= keyword outright with Unexpected keyword argument "default" for "TypeVar". Runtime support and checker support advance on separate tracks: a 3.13 interpreter understands default= natively, but if you run mypy 1.10 against it the code still fails to type-check, and conversely a modern checker can validate defaults in source that targets 3.9 as long as the constructors come from the backport.
For runtimes older than 3.13, import the parameter constructors from typing_extensions, which backports default= to TypeVar, ParamSpec, and TypeVarTuple alike. The one rule that trips people up: you must import the TypeVar (etc.) from typing_extensions, because only that object records the default; Generic, Protocol, and the class machinery can still come from typing. Mixing a typing.TypeVar with an attempt to attach a default silently loses the default on older runtimes.
# Python 3.11 / 3.12, checked with pyright 1.1.370 — backport
from typing import Generic
from typing_extensions import TypeVar
T = TypeVar("T", default="ServiceConfig") # forward ref default, pre-3.13 runtime
class Loader(Generic[T]):
...
reveal_type(Loader()) # Loader[ServiceConfig]
There is one presentation difference worth anticipating. When a generic is used bare, mypy and pyright now both fill in the default in reveal_type output — you see Box[int], not Box[Unknown] — but a stricter pyright configuration will still warn about the bare usage under reportMissingTypeArgument if you have opted into it, because the default is a fallback, not a licence to omit arguments everywhere. Under from __future__ import annotations (PEP 563) the annotations that reference Box are stringified, but the defaults declared on the TypeVar are evaluated when the TypeVar object is constructed, so from __future__ import annotations does not defer or change how a default resolves. The safest posture across a mixed fleet is to treat a default as a convenience for the common case and still parameterize explicitly at genuine API boundaries where the concrete type is known.
Defaults work for ParamSpec and TypeVarTuple too — a ParamSpec defaults to a full parameter list (e.g. P = ParamSpec("P", default=[int, str]) or default=... for “any parameters”), and a TypeVarTuple defaults to an unpacked tuple such as default=Unpack[tuple[int, ...]] — so the mechanism spans every kind of type parameter, not just plain TypeVars. All three answer the same runtime introspection API added in 3.13: T.has_default() returns a bool, and T.__default__ returns the declared default or the sentinel typing.NoDefault (typing_extensions.NoDefault on the backport) when none was given. Comparing against NoDefault — never against None, which is a legitimate default value — is the correct way to detect the absence of a default reflectively. See pyright vs mypy for the current support delta.
Common mistakes
The failure modes cluster into a few recognizable shapes: an ordering violation, a scope violation, a stale-tool problem, and a constraint contradiction. Each surfaces as a distinct diagnostic, so the error text usually points straight at the fix.
- Default before a non-default parameter:
class Bad[T = int, U]is rejected — pyrightreportGeneralTypeIssues, mypy[misc]. Move every defaulted parameter to the end of the list. The equivalentTypeVar-object spelling fails the same way at class-creation time, and on 3.13 it even raisesTypeErrorat runtime becauseGenericvalidates the ordering when the class body executes. - Referencing a later parameter in a default:
class Pair[T = U, U = int]fails becauseUis not yet in scope; a default may only reference parameters declared before it. Reorder so the referenced parameter comes first, or inline a concrete type. - Assuming old checkers understand
default=: on mypy before 1.12 or pyright before its PEP 696 release,TypeVar("T", default=int)is flagged as an unexpected keyword. Pin a recent analyzer version in CI so a green local run cannot mask a stale checker in the pipeline. - Mixing a bound and an incompatible default:
TypeVar("T", bound=str, default=int)is contradictory — the default must satisfy the bound, or the checker reports[type-var]/reportGeneralTypeIssues. The same holds for constraints: the default must be one of the listed constrained types. - Expecting a runtime effect: a default never changes what an object is at runtime, only what the checker infers where a parameter is omitted. If you need a real runtime fallback value, that is ordinary Python default-argument logic, not a type-parameter default.
FAQ
Do TypeVar defaults remove the need to write explicit type arguments?
Only for the fallback case. When you do supply an argument it always wins; the default merely means the bare, unparameterized generic is precisely typed instead of Unknown/Any. It is an ergonomics feature, not a replacement for parameterization where you know the type.
Can ParamSpec and TypeVarTuple have defaults too?
Yes. PEP 696 applies uniformly: a ParamSpec can default to a parameter list and a TypeVarTuple to a tuple of types, both via the same default= keyword (or PEP 695 = ... form). The ordering rule — defaults last — holds across all three kinds.