0. The running example
We need a small type hierarchy to make the examples concrete.
class Animal:
name: str
class Cat(Animal):
def purr(self) -> str: ...
class Dog(Animal):
def fetch(self, item: str) -> str: ...
Cat and Dog are subtypes of Animal. Wherever you need an Animal, you can pass a Cat or a Dog.
Cat is a subtype of Animal, then any function that accepts an Animal must also accept a Cat. This is the Liskov Substitution Principle.
1. The core question
The subtype principle above works for plain types. But what happens when you wrap types in a generic container?
If Cat is a subtype of Animal, is list[Cat] a subtype of list[Animal]?
There are exactly three answers a type system can give:
Box[Cat] and Box[Animal] are unrelated types. Neither is a subtype of the other.
This is the default in Python.
Box[Cat] is a subtype of Box[Animal]. Subtyping goes in the same direction as the type argument.
"co" = together, same direction.
Box[Animal] is a subtype of Box[Cat]. Subtyping goes in the opposite direction.
"contra" = against, reversed.
2. Invariance: the safe default
In Python, list is invariant. This means list[Cat] is not a subtype of list[Animal], even though Cat is a subtype of Animal.
def print_names(animals: list[Animal]) -> None:
for a in animals:
print(a.name)
cats: list[Cat] = [Cat("Whiskers"), Cat("Mittens")]
print_names(cats) # Type error! list is invariant
This might seem annoying. But there is a very good reason for it.
3. Why mutation forces invariance
Imagine Python did allow list[Cat] as a subtype of list[Animal]. Here is what could go wrong:
cats: list[Cat] = [Cat("Whiskers")]
# Suppose this were allowed...
animals: list[Animal] = cats
# Then this would be legal (Dog is an Animal)...
animals.append(Dog("Rex"))
# But cats and animals are the SAME list object!
cats[1].purr() # Runtime crash: Dog has no purr()!
list[Cat] as list[Animal] lets you sneak a Dog into a list of cats. The type checker prevents this by making mutable containers invariant.
The key insight: the problem only happens because the list is mutable. Both reading and writing are possible. If the container were read-only, there would be no way to sneak a Dog in.
You get a Cat out. Every Cat is an Animal. Safe.
You could put a Dog in. A Dog is not a Cat. Unsafe.
4. Covariance: same-direction subtyping
A covariant generic preserves the subtyping direction of its type argument. If Cat <: Animal, then Container[Cat] <: Container[Animal].
This is safe when the container is a producer — it only gives you values of type T, never accepts them.
from collections.abc import Sequence
def print_names(animals: Sequence[Animal]) -> None:
for a in animals:
print(a.name)
cats: list[Cat] = [Cat("Whiskers"), Cat("Mittens")]
print_names(cats) # OK! Sequence is covariant
Sequence is read-only. The function can iterate and index, but it cannot append or mutate. Every Cat it reads out is a valid Animal, so there is no way to corrupt the list.
The rule: producers (read-only containers) are covariant.
5. Contravariance: reversed subtyping
A contravariant generic reverses the subtyping direction. If Cat <: Animal, then Container[Animal] <: Container[Cat].
This is safe when the container is a consumer — it only accepts values of type T, never produces them.
from collections.abc import Callable
def apply_to_cat(fn: Callable[[Cat], None]) -> None:
fn(Cat("Whiskers"))
def feed(a: Animal) -> None:
print(f"Feeding {a.name}")
apply_to_cat(feed) # OK! A function that handles any Animal can handle a Cat
apply_to_cat will pass a Cat to fn. The function feed accepts any Animal. Since every Cat is an Animal, feed can certainly handle the Cat it receives.
The intuition: a function that can handle a broader type is more useful in a context that only needs a narrower type. More capable → more substitutable → subtype.
T without producing it. In practice, the most common example is Callable parameter types.
6. The Callable rule
Callable is the most common place where you encounter both covariance and contravariance in the same type.
For Callable[[P], R]:
A function that accepts a broader input type is more substitutable.
Accepts Animal → can be used where Cat is expected.
A function that returns a narrower output type is more substitutable.
Returns Cat → can be used where Animal is expected.
# A function that accepts broader input AND returns narrower output
# is MORE substitutable (a subtype).
def narrow_fn(a: Animal) -> Cat:
return Cat(a.name)
# narrow_fn can be used wherever Callable[[Cat], Animal] is expected:
# - It accepts Animal, which is broader than Cat (contravariant, OK)
# - It returns Cat, which is narrower than Animal (covariant, OK)
handler: Callable[[Cat], Animal] = narrow_fn # OK!
| Position | Variance | Why |
|---|---|---|
| Function parameter | Contravariant | Broader input → more substitutable |
| Function return | Covariant | Narrower output → more substitutable |
| Mutable container element | Invariant | Both read and written → must be exact |
| Read-only container element | Covariant | Only produced → narrower is fine |
| Write-only sink element | Contravariant | Only consumed → broader is fine |
7. Python syntax for declaring variance
Python has two syntax styles depending on your version. Both express the same concept.
Pre-3.12: TypeVar flags
from typing import TypeVar, Generic
# Invariant (default)
T = TypeVar("T")
# Covariant
T_co = TypeVar("T_co", covariant=True)
# Contravariant
T_contra = TypeVar("T_contra", contravariant=True)
class ReadOnlyBox(Generic[T_co]):
def __init__(self, value: T_co) -> None:
self._value = value
def get(self) -> T_co:
return self._value
# No setter! Covariant type params cannot appear in input positions.
class Sink(Generic[T_contra]):
def send(self, value: T_contra) -> None:
print(value)
# No getter returning T_contra! Contravariant type params
# cannot appear in output positions.
Python 3.12+: PEP 695 syntax
# Invariant (default)
class MutableBox[T]:
def get(self) -> T: ...
def set(self, value: T) -> None: ...
# Covariant: +T
class ReadOnlyBox[+T]:
def get(self) -> T: ...
# Cannot have set(self, value: T) — type checker rejects it
# Contravariant: -T
class Sink[-T]:
def send(self, value: T) -> None: ...
# Cannot have get(self) -> T — type checker rejects it
+T means covariant (subtyping in the positive / same direction). -T means contravariant (subtyping in the negative / opposite direction). Plain T means invariant.
+T (covariant), the type checker will reject any method that uses T in an input position (like a parameter type). If you declare -T (contravariant), it will reject T in output positions (like return types). These restrictions are what make the variance safe.
8. A concrete example: covariant container
Let's trace through a realistic example to see variance in action.
from collections.abc import Sequence, Iterator
def loudest_name(animals: Sequence[Animal]) -> str:
return max(a.name for a in animals, key=len)
def iter_names(animals: Iterator[Animal]) -> list[str]:
return [a.name for a in animals]
cats: list[Cat] = [Cat("Whiskers"), Cat("Mittens")]
loudest_name(cats) # OK — Sequence is covariant
iter_names(iter(cats)) # OK — Iterator is covariant
Both Sequence and Iterator are covariant because they only produce items. You can read from them, but you cannot insert new items through their interfaces.
Compare with a mutable version:
from collections.abc import MutableSequence
def add_animal(animals: MutableSequence[Animal]) -> None:
animals.append(Dog("Rex"))
cats: list[Cat] = [Cat("Whiskers")]
add_animal(cats) # Type error! MutableSequence is invariant
list[Cat] can be passed to a function expecting Sequence[Animal] (covariant, read-only view) but not to a function expecting MutableSequence[Animal] (invariant, read-write). The variance depends on the interface, not the concrete type.
9. Standard library variance
Here are the most common generic types in Python and their variance. Knowing these saves you from memorizing rules — just check whether the interface reads, writes, or both.
| Type | Variance | Reason |
|---|---|---|
list[T] |
Invariant | Mutable: reads and writes T |
set[T] |
Invariant | Mutable: reads and writes T |
dict[K, V] |
Invariant in both | Mutable: reads and writes both |
Sequence[T] |
Covariant | Read-only: only produces T |
Mapping[K, V] |
Covariant in V | Read-only: only produces V |
Iterator[T] |
Covariant | Read-only: only yields T |
Iterable[T] |
Covariant | Read-only: only yields T |
Callable[[P], R] |
Contra in P, Co in R | Consumes P, produces R |
type[C] |
Covariant | Produces instances of C |
frozenset[T] |
Covariant | Immutable: only produces T |
Sequence, Mapping, Iterable), it's covariant.
10. The decision guide
When you are defining your own generic class, use this to decide the variance:
Use covariant (+T).
Examples: read-only containers, iterators, factories.
Use contravariant (-T).
Examples: sinks, callbacks, comparators, serializers.
Use invariant (plain T).
Examples: mutable containers, read-write refs.
T when it appears in a return type or an outgoing position. A method consumes T when it appears in a parameter type or an incoming position. If T appears in both positions, the class must be invariant.
11. The cheat sheet
Same direction. Read-only / producer.
Reversed. Write-only / consumer.
No subtype relationship. Read-write.
Demand less, promise more.
Explicit flag on the TypeVar.
+ for covariant, - for contravariant.
If you can put values in, covariance is unsound.
If you can get values out, contravariance is unsound.
12. Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
Using list[Animal] in function signatures when you only read |
Callers with list[Cat] get a type error |
Use Sequence[Animal] instead |
Declaring +T on a class that has a setter |
Type checker rejects the setter | Use plain T (invariant) or remove the setter |
Assuming tuple[Cat, ...] is invariant |
Surprise: tuple is covariant (it's immutable) |
No fix needed — just know it works |
| Ignoring variance in callback types | Passing a Callable[[Cat], None] where Callable[[Animal], None] is needed |
The callback must accept the broader type |
list[X] to Sequence[X]. This single change resolves the majority of variance-related type errors in real Python code.