Why list Is Invariant but Sequence Is Covariant

TL;DR

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 for list invariance A list of int passed as list of float lets a function append a float, so the original caller reading ints finds a float. nums: list[int] [1, 2, 3] passed as values: list[float] values.append(3.14) back in caller: nums = [1, 2, 3, 3.14] reading nums[3] as int is now wrong so the checker forbids the first step: [arg-type]
Allowing list[int] where list[float] is expected would let a float leak back to the caller.

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.

Proof by contradiction for list invariance Each step follows from the one above until the final read of an int slot yields a float, a contradiction the checker avoids. Assume the substitution is allowed — follow it down 1 · Assume list[int] is a list[float] 2 · The list[float] parameter may append 3.14 3 · Caller still holds the same object as list[int] 4 · nums[3] is typed int but holds 3.14 — contradiction so step 1 must be rejected: [arg-type]
Assuming the substitution and following its consequences forces a float into a slot the caller reads as int.
# 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.

Invariance blocks both directions A list of int is not a list of float and a list of float is not a list of int, so both substitution directions are rejected. list[int] list[float] ✗ [arg-type] ✗ [return-value] / [assignment] exact element match only — no substitution either way
Invariance is symmetric: neither element-type substitution is accepted.
# 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.

list parameter versus Sequence parameter Changing the parameter from list of float to Sequence of float turns a rejected list of int argument into an accepted one, because Sequence is covariant. Before — list[float] param def mean(v: list[float]) mean(nums) # nums: list[int] ✗ [arg-type] invariant — exact match only After — Sequence[float] param def mean(v: Sequence[float]) mean(nums) # unchanged caller ✓ accepted covariant — Seq[int] <: Seq[float]
Only the annotation changes; the caller passes the same concrete list.
# 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 IterableCollectionSequence: 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.

Runtime vs static analysis Variance is a purely static rule — Python's runtime does not track element types at all. 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.

Mistakes mapped to error codes Passing, returning, or binding an invariant list at the wrong element type each triggers a specific mypy error code, all fixed by a read-only type. What you wrote mypy code pass list[int] where list[float] wanted [arg-type] return list[float] from -> list[int] [return-value] bind list[int] to a list[float] var [assignment] one invariance rule, three positions — same fix: a read-only ABC
The same invariance surfaces under three codes; each fix is to widen to a read-only type.
  • Typing a read-only parameter as list[...]: callers with a tuple or a differently-typed list are rejected with [arg-type] even though you never mutate. Use Sequence or Iterable to widen what you accept.
  • Expecting list[int] to satisfy list[float]: it never will — [arg-type] in mypy, reportArgumentType in pyright. Change the parameter to Sequence[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 a list[float] variable: [assignment]. The same rule as arguments, surfaced at the =; annotate the target Sequence[float] if you only read it afterwards.
  • Assuming all generics are invariant: they are not. Sequence, Iterable, Mapping (in its value) and frozenset are covariant; Callable is contravariant in its arguments. Only mutable containers like list, set, and dict are 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.

Back to Variance and Type Parameters