Why list Is Invariant but Sequence Is Covariant
list[int] is not a list[float], even though int is compatible with float, because lists are mutable — treating one as the other would let you append a float into a list that someone else still reads as ints. That unsoundness is why list is invariant. Read-only Sequence and Iterable are covariant, so a Sequence[int] is a Sequence[float]. Type function parameters as Sequence (or Iterable) when you only read them.
Variance is the rule that decides whether Container[Sub] may stand in for Container[Super]. For mutable containers the answer is “no,” and the reason is entirely about writes. This page belongs to Variance and Type Parameters and works through the concrete unsound-append argument, the exact error codes, and the read-only fix.
The unsound-append argument
Assume, for contradiction, that list[int] were assignable to list[float]. A function taking list[float] is entitled to append a float. But the caller still holds the same object typed as list[int] and will read its elements as int. One shared mutation breaks the caller’s promise.
# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
def add_pi(values: list[float]) -> None:
values.append(3.14) # legal: a float list may receive a float
nums: list[int] = [1, 2, 3]
add_pi(nums) # mypy: [arg-type]
# error: Argument 1 to "add_pi" has incompatible type "list[int]"; expected "list[float]"
reveal_type(nums[3]) # statically int, but 3.14 at runtime — would be a lie
mypy rejects the call with [arg-type]; pyright rejects it as reportArgumentType. The checker forbids the first step so the corrupting append can never happen. This is the whole justification for list being invariant in its element type.
The int/float pairing makes this sharper than it first looks, because int is not a subclass of float: issubclass(int, float) returns False, and there is no inheritance between the two. Type checkers special-case the relationship under PEP 484’s numeric tower — a parameter annotated float also accepts int, and one annotated complex accepts both float and int, as a typing rule that has no runtime counterpart. That scalar-level assignability is exactly what makes the container example tempting, and exactly why it must still be refused: int being usable as a float does not lift to list[int] being usable as list[float], because the container adds a write channel the scalar relationship knows nothing about.
Variance is a property of the generic class, fixed in its stub, not something you opt out of per call site. In typeshed — the stub library both mypy and pyright consume — list is declared class list(MutableSequence[_T]) with a plain invariant _T = TypeVar("_T"), while Sequence uses a covariant _T_co = TypeVar("_T_co", covariant=True). Because list.append(self, object: _T) puts _T in an input position and list.__getitem__(self, i) -> _T puts it in an output position, the element type is used both ways and can only be invariant. No annotation on your own variable changes that. mypy even spells out the remedy, appending "list" is invariant; consider using "Sequence" instead, which is covariant beneath the [arg-type] line.
The write surface is larger than append alone: insert, extend, __setitem__, __iadd__ (the lst += ... form), and slice assignment all place the element type in an input position, and any single one of them is enough to force invariance. This is why every mutable container follows the same rule — set.add, dict.__setitem__, bytearray extension — and why you cannot rescue covariance by subclassing: a class Ints(list[int]): ... inherits append, so Ints is invariant too. The only way to expose an element type covariantly is to hide the writers behind a read-only interface, which is exactly what Sequence does.
Invariance means both directions fail
Invariance is symmetric. Not only is list[int] not a list[float], but list[float] is not a list[int] either — the second would let a reader pull an int slot out of a list that actually holds floats.
# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
def totals() -> list[int]:
data: list[float] = [1.0, 2.0]
return data # mypy: [return-value]
# error: Incompatible return value type (got "list[float]", expected "list[int]")
Neither substitution is safe, so a mutable generic accepts only an exact element-type match. The symmetry trips up people who reason only from “int fits in float”. Assignment is checked by the same rule as argument passing, so a plain binding fails too — surfacing a third error code:
# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
nums: list[int] = [1, 2, 3]
vals: list[float] = nums # mypy: [assignment]
# error: Incompatible types in assignment
# (expression has type "list[int]", variable has type "list[float]")
So one invariance rule surfaces under three distinct mypy codes depending on syntactic position: [arg-type] at a call, [return-value] at a return, and [assignment] at a binding. All three report the same fact — list demands an exact element-type match. Contrast this with a covariant type, which relaxes exactly one direction: Sequence[int] is a Sequence[float], but Sequence[float] is still not a Sequence[int]. Covariance opens one direction; invariance closes both. Nothing in the standard library is bivariant (both directions legal at once) — that would be maximally unsound, so no built-in container offers it.
The reverse direction is unsound for a mirror-image reason. If list[float] were assignable to list[int], a reader holding the int view could call an int-only method on an element that is really a float:
# Python 3.12+ — why list[float] is not a list[int] either
def bit_lengths(xs: list[int]) -> list[int]:
return [x.bit_length() for x in xs] # bit_length exists on int, not float
data: list[float] = [1.5, 2.5]
bit_lengths(data) # mypy: [arg-type] — blocked at the call
# had it been allowed, (1.5).bit_length() would raise AttributeError at runtime
Here nothing is even mutated; the corruption is entirely on the read side. That is the deep reason invariance must be symmetric: a mutable element type is simultaneously a producer (elements read out must stay sound, which pushes toward covariance) and a consumer (elements written in must stay sound, which pushes the opposite way toward contravariance), and the only relation that satisfies both constraints at once is equality — an exact type match.
The fix: accept a read-only Sequence
If a function only reads its argument, annotate the parameter with an immutable, covariant type. Sequence (from collections.abc) has no append, so the write that made the substitution unsound is simply not part of the interface — and the checker can safely allow list[int] where Sequence[float] is expected.
# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Sequence
def mean(values: Sequence[float]) -> float: # read-only, covariant
return sum(values) / len(values)
nums: list[int] = [1, 2, 3]
mean(nums) # OK — Sequence[int] is a Sequence[float]
Sequence[T] is declared covariant in T, so Sequence[int] is a subtype of Sequence[float]. Iterable[T] is covariant too and is the right choice when you only iterate once and never index. For the fuller picture of these ABCs, see typing collections and collections.abc.
Reach for the weakest read-only interface you actually use. The abstract base classes nest as Iterable ⊂ Collection ⊂ Sequence: Sequence gives you indexing, slicing and len(); Collection drops ordering but keeps in and len(); and Iterable promises only a single pass. If you never index, Iterable[float] accepts sets and lazy generators as well as lists:
# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Iterable
def total(values: Iterable[float]) -> float: # single pass, covariant
return sum(values)
total([1, 2, 3]) # list[int] — OK
total({1, 2, 3}) # set[int] — OK
total(x * x for x in range(4)) # generator — OK
Import these from collections.abc, not typing: since Python 3.9 (PEP 585) typing.Sequence and typing.Iterable are deprecated aliases of the real ABCs, which subscript directly. typing.Sequence still works and is unavoidable on 3.8, but new code — or any module with from __future__ import annotations — should prefer collections.abc. The covariance extends beyond sequences: tuple[int, ...] is a tuple[float, ...], frozenset[int] is a frozenset[float], and Mapping[str, int] is a Mapping[str, float] because a Mapping’s value type is covariant (its key type stays invariant). Only the mutable concrete containers — list, set, dict — impose the exact-match rule. See covariant vs invariant collections explained for the full catalogue.
When the function genuinely needs to write to the argument, do not reach for a read-only type — the invariance is protecting you. Keep the concrete list[T], or annotate MutableSequence[T] if you want to accept any writable sequence, and require the caller to pass the exact element type. If instead you want to accept subtype collections and return the same element type the caller gave you, make the function generic with a bounded TypeVar rather than widening to Sequence[float], which would forget the element type entirely:
# Python 3.12+, PEP 695 syntax — preserve the caller's element type
from collections.abc import Sequence
def first[T: float](values: Sequence[T]) -> T: # T bound by float
return values[0]
reveal_type(first([1, 2, 3])) # revealed type: "int", not "float"
reveal_type(first([1.0, 2.0])) # revealed type: "float"
The bound T: float still admits int elements (via the numeric tower) while threading the concrete type through to the return, so a caller who passes a list[int] gets an int back — something a plain Sequence[float] parameter could never express, because it erases the element type down to float.
A quick way to confirm you have picked the right type is reveal_type(), which prints during type checking and is a no-op at runtime. Annotate the parameter, run the checker, and verify that the revealed type at the call site is what you expect and that no [arg-type] remains. If the error persists after switching to Sequence, the function is almost certainly still writing through the parameter somewhere — scan the body for .append, .sort(), +=, or an index assignment, each of which requires the mutable type and re-imposes invariance.
add_pi(nums) would execute fine and silently append 3.14 to your int list; nothing raises. The checker's [arg-type] is your only warning that the mutation corrupts the caller's view. Annotating parameters as Sequence makes the safe intent explicit.
Common mistakes
Nearly every mutable-container variance error is one of the following. Each maps to a specific checker code, and every fix is the same move — widen the parameter to a read-only type, or build the exact element type you promised.
- Typing a read-only parameter as
list[...]: callers with atupleor a differently-typed list are rejected with[arg-type]even though you never mutate. UseSequenceorIterableto widen what you accept. - Expecting
list[int]to satisfylist[float]: it never will —[arg-type]in mypy,reportArgumentTypein pyright. Change the parameter toSequence[float], or convert with[float(x) for x in nums]if you truly need a float list. - Returning a
list[float]from a-> list[int]function:[return-value]. Invariance blocks the return; build the exact element type you promise. - Binding a
list[int]to alist[float]variable:[assignment]. The same rule as arguments, surfaced at the=; annotate the targetSequence[float]if you only read it afterwards. - Assuming all generics are invariant: they are not.
Sequence,Iterable,Mapping(in its value) andfrozensetare covariant;Callableis contravariant in its arguments. Only mutable containers likelist,set, anddictare invariant.
The single habit that eliminates most of these is to type public parameters by capability rather than by concrete class: ask whether the function reads or writes the collection, then pick the weakest type that supports what it actually does. Read-only readers take Iterable, Sequence, or Mapping; genuine mutators keep list, set, dict, or a Mutable* ABC and accept the exact-match constraint as the price of being allowed to write. Reserving the concrete mutable types for functions that truly mutate turns invariance from a recurring annoyance into a signal that a boundary is doing more than it claims.
Why can’t the checker just allow the substitution when I promise not to append?
Static analysis cannot see a runtime promise; it can only see the type. As long as the parameter is list[float], any code in that function (or anything it hands the list to) is allowed to append. The type system has to assume the whole capability is available, so it rejects the call up front. Narrow the type to Sequence[float] to encode “read-only” in a way the checker can trust.
Is tuple invariant like list?
No. tuple is immutable, so tuple[int, ...] is covariant and is a tuple[float, ...]. That is another reason to prefer immutable containers in interfaces: they compose with subtyping the way intuition expects, whereas list does not.