Why list[Dog] Is Not list[Animal] but Sequence[Dog] Is
Mutable collections like list and dict are invariant: list[Dog] is not assignable to
list[Animal], because the callee could append a Cat and corrupt your list. Read-only abstract
types — Sequence, Mapping, Iterable, and tuple — are covariant, so Sequence[Dog] is a
Sequence[Animal]. Fix [arg-type] / reportArgumentType errors by annotating parameters you only
read with those abstract types instead of the concrete mutable ones.
This is the single most common generics error a static analyzer reports: you pass a list[Dog] to a
function annotated list[Animal] and mypy rejects it. The reason is
variance — specifically the
producer/consumer rule applied to real collection types. This guide explains why each standard
collection is invariant or covariant and shows the one-line fix that makes the error disappear
correctly, without suppressing a genuine soundness problem. It assumes Python 3.11+ and mypy 1.x or
pyright.
Context: the producer/consumer rule
A collection’s element type can be covariant only if the collection never lets you write that
element. A list is both readable and writable, so its element type sits in input and output
positions and must be invariant. A Sequence exposes only reads (__getitem__, iteration), so its
element type is output-only and can be covariant. The same logic splits dict (invariant) from
Mapping (covariant in its value type). This is PEP 483’s variance rule applied to the standard
library.
The rule is mechanical and worth stating precisely. For a generic type C[T], T may be
covariant if it appears only in output (return) positions of C’s methods, contravariant
if it appears only in input (parameter) positions, and must be invariant if it appears in
both. list.__getitem__ returns T while list.append and list.__setitem__ take T, so the
element type is used both ways — invariant. Every method on collections.abc.Sequence
(__getitem__, __iter__, __contains__, __reversed__, index, count) produces or inspects
T without ever storing one, so its element type is output-only.
You can see this directly in typeshed, the stub library both mypy and pyright consume. Sequence
is declared with a covariant type variable, conventionally suffixed _co, while MutableSequence
reintroduces the writing methods and falls back to an invariant variable.
# Sketch of the typeshed declarations (simplified)
from typing import TypeVar
_T_co = TypeVar("_T_co", covariant=True) # produces values
_T = TypeVar("_T") # invariant (no covariant=/contravariant=)
class Sequence(Collection[_T_co]):
def __getitem__(self, index: int) -> _T_co: ... # output only → covariant
class MutableSequence(Sequence[_T]):
def insert(self, index: int, value: _T) -> None: ... # input position → invariant
def append(self, value: _T) -> None: ...
The mnemonic is producer/consumer: a type that only produces T (hands it to you) can be
covariant; a type that only consumes T (takes it from you) can be contravariant; a type that
does both must be invariant. The contravariant case is easiest to see with function types, covered
in variance and type parameters:
Callable[[Animal], None] is a subtype of Callable[[Dog], None], not the other way round.
Step 1 — Reproduce the invariance error
Passing a list[Dog] where list[Animal] is wanted is rejected, even though Dog is a subtype of
Animal.
# Python 3.11+, mypy 1.x / pyright
class Animal: ...
class Dog(Animal): ...
class Cat(Animal): ...
def add_stray(animals: list[Animal]) -> None:
animals.append(Cat()) # legal for a list[Animal]
dogs: list[Dog] = [Dog()]
add_stray(dogs) # mypy error: [arg-type]
# pyright: reportArgumentType — "list[Dog]" is not assignable to "list[Animal]"
The error is correct: add_stray would insert a Cat into something the caller believes is a
list[Dog]. Invariance prevents that.
Both checkers agree here, and there is no flag that relaxes it: list invariance is unconditional,
not a strictness setting. The error surfaces at the call site — the argument, not the function
body — and mypy tags it [arg-type] while pyright emits reportArgumentType. The same invariance
shows up on a plain assignment, where mypy reports [assignment] instead:
# Same invariance, surfaced at an assignment rather than a call
dogs: list[Dog] = [Dog()]
animals: list[Animal] = dogs # mypy error: [assignment]
# error: Incompatible types in assignment
# (expression has type "list[Dog]", variable has type "list[Animal]")
It helps to read the message literally: “assignable” (pyright) and “compatible type” (mypy) both
mean is a subtype of. list[Dog] is not a subtype of list[Animal] even though Dog <: Animal,
because a generic’s subtyping is governed by its variance, not by its arguments’ subtyping. You can
confirm the checker’s view with reveal_type(), which prints during type checking and is a no-op at
runtime; it will report the parameter as list[Animal] inside add_stray, confirming that append
is fully available there. That capability is exactly what makes accepting a list[Dog] unsound.
Step 2 — Switch a read-only parameter to Sequence
If the function only reads the collection, annotate it as Sequence[Animal]. Sequence is
covariant, so Sequence[Dog] is accepted as a Sequence[Animal].
# Python 3.11+, mypy 1.x — read-only → covariant
from collections.abc import Sequence
def describe(animals: Sequence[Animal]) -> None:
for a in animals:
print(type(a).__name__) # only reads — never appends
dogs: list[Dog] = [Dog()]
describe(dogs) # accepted: Sequence[Dog] <: Sequence[Animal]
A list is a Sequence, so callers pass their concrete list unchanged — you only relaxed the
parameter type.
Reach for the weakest interface you actually use — the abstract base classes nest as
Iterable ⊂ Collection ⊂ Sequence. If you only loop once and never index, Iterable[Animal]
is broader still and admits sets and generators as well:
# Pick the weakest interface you actually use
from collections.abc import Iterable
def describe(animals: Iterable[Animal]) -> None:
for a in animals: # single pass, no indexing
print(type(a).__name__)
describe([Dog()]) # list is Iterable
describe((Dog(), Cat())) # tuple is Iterable
describe({Dog()}) # set is Iterable too
Import these from collections.abc, not typing: since Python 3.9 (PEP 585) the typing.Sequence
/ typing.Iterable aliases are deprecated in favour of the real ABCs, which subscript directly.
typing.Sequence still works and is unavoidable if you must run on 3.8, but new code — or code with
from __future__ import annotations — should prefer collections.abc. See
Sequence vs list in function parameters
for the fuller argument that read-only parameters should almost always be abstract.
Step 3 — Apply the same fix to mappings
dict is invariant in both its key and value type. If a function only looks values up, accept a
Mapping[str, Animal], which is covariant in the value type.
# Python 3.11+, mypy 1.x
from collections.abc import Mapping
def first_name(registry: Mapping[str, Animal]) -> str:
return next(iter(registry)) # read-only
shelter: dict[str, Dog] = {"rex": Dog()}
first_name(shelter) # accepted via Mapping covariance
Note the asymmetry between the two type parameters. In typeshed Mapping is
Mapping(Collection[_KT], Generic[_KT, _VT_co]): the value variable _VT_co is covariant, but the
key variable _KT is invariant, because a key is both accepted by __getitem__(self, key) and
produced by keys(). So value-widening works while key-widening does not:
# Value is covariant; key is NOT
from collections.abc import Mapping
def names(reg: Mapping[str, Animal]) -> list[str]:
return list(reg)
shelter: dict[str, Dog] = {"rex": Dog()}
names(shelter) # OK: value Dog <: Animal
wide: Mapping[object, Animal] = shelter # mypy error: [assignment]
# key type of Mapping is invariant — str is not object here
MutableMapping reintroduces __setitem__, so it is invariant in the value too; use it only when
the function genuinely stores into the mapping. As with sequences, the concrete dict satisfies
Mapping with no conversion and no runtime cost — you are just narrowing the interface the callee
is allowed to touch.
Step 4 — Know which collections are already covariant
tuple and the read-only abstract base classes need no fix — they are covariant out of the box
because they expose no mutators.
# Python 3.11+, mypy 1.x / pyright
def total(values: tuple[Animal, ...]) -> int:
return len(values)
dogs: tuple[Dog, ...] = (Dog(), Dog())
total(dogs) # accepted: tuple is covariant (immutable)
Both the homogeneous tuple[T, ...] and fixed-length forms like tuple[Dog, Cat] are covariant
position by position, because a tuple never rebinds a slot. frozenset is covariant while set is
not; the abstract collections.abc.Set (aliased as AbstractSet) is the covariant read-only view
of either:
from collections.abc import Set as AbstractSet
def count_unique(a: AbstractSet[Animal]) -> int:
return len(a)
frozen: frozenset[Dog] = frozenset({Dog()})
count_unique(frozen) # OK: frozenset is covariant
count_unique({Dog(), Dog()}) # OK: a set satisfies the read-only AbstractSet
The practical takeaway is a design guideline: prefer immutable containers and read-only ABCs at your
public boundaries. They compose with subtyping the way intuition expects — tuple[Dog, ...] really
is a tuple[Animal, ...] — whereas the mutable concrete types force exact-match element types on
every caller.
Edge cases
- A function that both reads and writes: Keep the invariant
list[Animal]. The error is real — you cannot soundly acceptlist[Dog]if you ever append a non-Dog. setandfrozenset:setis invariant (mutable);frozensetis covariant. The mutable/ immutable split is the deciding factor again.- Covariant return, invariant field: A method may return
Sequence[Animal]covariantly while a storedlist[Animal]attribute stays invariant — annotate the boundary, not the storage.
Two more edges are worth knowing. First, a list literal is inferred against its expected type, so
this compiles even though a pre-typed list[Dog] would not — a common source of confusion about
when invariance “bites”:
# A list literal is inferred at the expected type — this is fine
animals: list[Animal] = [Dog(), Cat()] # OK: literal, not a pre-typed list[Dog]
# The invariance error only fires when an *already-typed* list[Dog] is passed/assigned.
Second, when a function must both accept subtype collections and preserve the exact element type in its result, invariance is not the tool — a generic function is. A bounded TypeVar threads the concrete type through:
from typing import TypeVar
from collections.abc import Sequence
A = TypeVar("A", bound=Animal)
def first(animals: Sequence[A]) -> A: # preserves the element type
return animals[0]
reveal_type(first([Dog(), Dog()])) # Revealed type is "Dog", not "Animal"
Common mistakes
- Annotating read-only parameters as
list: This forces invariance and rejects valid subtype collections with[arg-type]/reportArgumentType. UseSequence/Iterable/Mappingfor read-only parameters. - Suppressing the error with
# type: ignore: On a function that mutates, the error is genuine; ignoring it lets aCatinto alist[Dog]. Fix the type, do not silence[arg-type]. - Expecting
dict[str, Dog]to satisfydict[str, Animal]:dictvalues are invariant. UseMapping[str, Animal]if the function only reads, or you will hit[arg-type].
Two further traps are worth calling out. Using List/Dict imported from typing changes nothing
about variance — typing.List is just the deprecated spelling of list and is equally invariant;
the covariance you want comes from choosing Sequence/Mapping, not from a different import.
Likewise, reaching for Any (animals: list[Any]) does silence the error, but it silences all
element checking inside the function, so a genuine bug — indexing the wrong attribute on an element —
now goes unreported too. Prefer a precise read-only type: it accepts the subtype collections you
care about while keeping the element type fully checked. When you truly need a concrete list of the
wider type, build one explicitly with [a for a in animals] or list(animals), which copies rather
than aliasing, so no later append can reach the caller’s original object.
FAQ
Why is tuple covariant but list is not?
tuple is immutable, so its element type appears only in output positions and can be covariant.
list is mutable — read and written — so it must be invariant.
Does using Sequence slow anything down?
No. Sequence is an abstract base class for typing only; you still pass the same concrete list at
runtime, with identical performance.