← Concept Cache

Covariance & Contravariance

When is Box[Cat] a subtype of Box[Animal]? A practical guide to variance in Python generics.

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: ...
Type hierarchy

Cat and Dog are subtypes of Animal. Wherever you need an Animal, you can pass a Cat or a Dog.

Animal Cat Dog is-a is-a Cat and Dog are subtypes of Animal
Subtype principle: if 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:

Invariant

Box[Cat] and Box[Animal] are unrelated types. Neither is a subtype of the other.

This is the default in Python.

Covariant

Box[Cat] is a subtype of Box[Animal]. Subtyping goes in the same direction as the type argument.

"co" = together, same direction.

Contravariant

Box[Animal] is a subtype of Box[Cat]. Subtyping goes in the opposite direction.

"contra" = against, reversed.

Variance is the term for how a generic type's subtyping relates to its type argument's subtyping. Choosing the wrong variance leads to type errors or, worse, runtime crashes.

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()!
The mutation problem: if you can write to a container, then treating 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.

Reading from the list

You get a Cat out. Every Cat is an Animal. Safe.

Writing to the list

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
Why this is safe: 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.

Covariance: subtyping goes in the same direction
Types: Cat is-a Animal Generics: Seq[Cat] subtype Seq[Animal] ↓↓ same

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
Why this is safe: 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.

Contravariance: subtyping goes in the opposite direction
Types: Cat is-a Animal Generics: Sink[Animal] subtype Sink[Cat] ↑↓ reversed
When does contravariance come up? Mostly with callbacks, event handlers, comparators, and any generic that consumes 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]:

Parameters (P): contravariant

A function that accepts a broader input type is more substitutable.

Accepts Animal → can be used where Cat is expected.

Return type (R): covariant

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!
Mental model: a function is more useful (more substitutable) when it demands less from its caller (accepts broader input) and promises more to its caller (returns narrower output). Demand less, promise more = subtype.
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
Syntax mnemonic: +T means covariant (subtyping in the positive / same direction). -T means contravariant (subtyping in the negative / opposite direction). Plain T means invariant.
The type checker enforces variance rules. If you declare +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
Same list, different interfaces: a 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
Practical rule of thumb: if the type name starts with "Mutable" or you know it supports mutation, it's invariant. If it's a read-only view (Sequence, Mapping, Iterable), it's covariant.

10. The decision guide

When you are defining your own generic class, use this to decide the variance:

Does it only produce T?

Use covariant (+T).

Examples: read-only containers, iterators, factories.

Does it only consume T?

Use contravariant (-T).

Examples: sinks, callbacks, comparators, serializers.

Does it both produce and consume T?

Use invariant (plain T).

Examples: mutable containers, read-write refs.

Produce vs consume: a method produces 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

Covariant (+T) Cat <: Animal ⇒ Box[Cat] <: Box[Animal]

Same direction. Read-only / producer.

Contravariant (-T) Cat <: Animal ⇒ Sink[Animal] <: Sink[Cat]

Reversed. Write-only / consumer.

Invariant (T) list[Cat] is unrelated to list[Animal]

No subtype relationship. Read-write.

Callable[[P], R] P contravariant, R covariant

Demand less, promise more.

Pre-3.12 syntax TypeVar("T", covariant=True)

Explicit flag on the TypeVar.

3.12+ syntax class Foo[+T] / class Bar[-T]

+ for covariant, - for contravariant.

The mutation test Can write T? → not covariant

If you can put values in, covariance is unsound.

The read test Can read T? → not contravariant

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
Most common fix: if a function only reads from a collection, change the parameter type from list[X] to Sequence[X]. This single change resolves the majority of variance-related type errors in real Python code.