Sequence vs list in Function Parameters
TL;DR — For a parameter you only read, annotate Sequence[T], not list[T]. list is invariant, so list[T] rejects list[subtype] callers and refuses tuples and strings outright. Reach for MutableSequence[T] only when the function actually calls append, sort, or slice assignment on the argument.
A parameter annotation is a promise about what the function does with the value. list[int] promises two things a reader may not intend: “I might mutate this” and “it must be exactly a list.” Both promises quietly shrink your caller set. Sequence[int] promises only “I will read this by index or iteration,” which is what most functions actually do.
Sequence[int] takes list, tuple, and range; list[int] refuses everything but an exact list[int].list is invariant, and that costs you callers
list is invariant in its element type. That means list[bool] is not accepted where list[int] is expected, even though every bool is an int. The reason is soundness: a function holding a list[int] could append a plain 2, which would be an invalid element of the caller’s list[bool].
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
def sum_scores(scores: list[int]) -> int:
return sum(scores)
flags: list[bool] = [True, True, False]
sum_scores(flags)
# mypy: Argument 1 to "sum_scores" has incompatible type "list[bool]";
# expected "list[int]" [arg-type]
# pyright: reportArgumentType
The function never mutates scores, so the rejection is a false alarm produced entirely by the annotation. This is the same invariance rule covered in depth under variance and type parameters.
Sequence is covariant and accepts the whole family
Sequence[T] from collections.abc is covariant in T, and it is satisfied by any indexable, sized, iterable object. Switch the annotation and every earlier caller type-checks:
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Sequence
def sum_scores(scores: Sequence[int]) -> int:
return sum(scores)
sum_scores([1, 2, 3]) # list[int] ✓
sum_scores((10, 20)) # tuple[int, ...] ✓
sum_scores(range(5)) # range ✓
sum_scores([True, False]) # list[bool] ✓ (covariant)
You lose nothing: sum, len, indexing, slicing, and iteration are all part of the Sequence contract. You only give up mutation methods — which you were not calling.
MutableSequence when you really do mutate
When the function does mutate the argument, say so with MutableSequence[T]. It advertises append, extend, insert, __setitem__, and slice assignment, and it correctly rejects immutable callers like tuples at the call site rather than at a runtime AttributeError.
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import MutableSequence
def dedupe_in_place(items: MutableSequence[str]) -> None:
seen: set[str] = set()
write = 0
for value in items:
if value not in seen:
seen.add(value)
items[write] = value # needs __setitem__
write += 1
del items[write:] # needs slice deletion
dedupe_in_place(("a", "b"))
# mypy: Argument 1 has incompatible type "tuple[str, str]";
# expected "MutableSequence[str]" [arg-type]
Concrete list[str] would also work here, but MutableSequence[str] documents intent — “I mutate, but any mutable sequence will do” — and still admits things like collections.deque.
A quick decision rule
Walk the body of the function and note which operations you perform on the parameter. If it is only iteration, len, indexing, or slicing, the answer is Sequence[T]. If you also mutate — append, sort, items[i] = x, del items[i] — the answer is MutableSequence[T] (or concrete list[T] when you specifically want to hand the caller something list-shaped). You almost never need list[T] on a parameter; it appears mostly out of habit.
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Sequence
class OrderRepository:
def total_amount(self, line_items: Sequence[int]) -> int:
return sum(line_items) # read-only → Sequence
def append_audit(self, log: list[str], entry: str) -> None:
log.append(entry) # mutates a caller-owned list → list is honest
The split reads as documentation: total_amount cannot alter its input, append_audit announces that it will.
The str gotcha
str satisfies Sequence[str]: it is indexable, sized, and iterates into one-character strings. So a parameter typed Sequence[str] silently accepts a bare string and treats it as a sequence of characters — no analyzer error whatsoever.
# Python 3.9+, mypy 1.10 — no error is raised here
from collections.abc import Sequence
def join_tags(tags: Sequence[str]) -> str:
return ",".join(tags)
join_tags("prod") # returns "p,r,o,d" — accepted, silently wrong
join_tags("prod"), because str genuinely is a Sequence[str] — the bug is semantic, not a type error. If passing a lone string is a real hazard, validate at runtime (if isinstance(tags, str): raise TypeError(...)) or annotate the parameter list[str] so a bare str is rejected as [arg-type]. Static typing cannot express "any sequence except str."
Common mistakes
list[T]on a read-only parameter. Rejectslist[subtype]and every non-list caller with mypy[arg-type]/ pyrightreportArgumentType. UseSequence[T].Sequence[T]where you call.append(). mypy reports"Sequence[T]" has no attribute "append"[attr-defined]; upgrade toMutableSequence[T].- Forgetting
stris a sequence.Sequence[str]accepts a lone string with no error; guard at runtime if that is invalid. - Returning
Sequence[T]then mutating the result. The caller sees a read-only type and mypy blocks.append()on it with[attr-defined]; returnlist[T]when callers must mutate.
FAQ
Does using Sequence slow anything down at runtime?
No. Annotations are erased at runtime under normal execution; Sequence[int] and list[int] produce identical bytecode for the function body. The difference is purely what the type checker accepts.
Should return types also be Sequence?
Only if you want to stop callers from mutating the result. For a return, prefer the concrete list[T] when callers legitimately need to append or sort; use Sequence[T] to keep the backing store swappable.