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.

Accepted callers: Sequence[int] versus list[int] list of int, tuple of int, and range are all accepted by Sequence of int; list of int is accepted by list of int, but tuple, range, and list of bool are rejected with an arg-type error. list[int] tuple[int, ...] range list[bool] param: Sequence[int] param: list[int] all accept ✓ only list[int] ✓
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]. Invariance is not an implementation quirk of mypy — it is required by PEP 483’s typing theory for any mutable container, because a mutable container is used in both a read (covariant) and a write (contravariant) position, and the only variance compatible with both is invariance.

The unsound write that invariance prevents If list of bool were accepted as list of int, the callee could append a plain int and corrupt the caller's list of bool, so the type system forbids the assignment. what invariance stops caller owns list[bool] passed as callee sees list[int] scores.append(2) caller's list now holds 2 2 is not a bool → corruption
The write position is what forces invariance: allowing list[bool] where list[int] is expected would let the callee insert a non-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. Pyright words it slightly differently — Argument of type "list[bool]" cannot be assigned to parameter "scores" of type "list[int]" ... "list[bool]" is incompatible with "list[int]" — but the diagnosis is the same. Note that bool subclassing int is a real language fact (PEP 285), which is exactly why this example is not contrived: the subtype relationship holds, yet invariance still blocks it. The same rule bites list[int] against list[float]: even though the numeric tower (PEP 484 treats int as compatible with float), list[int] is not a list[float].

Invariance is symmetric across parameter and return positions, because it is a property of the list type itself rather than of where the type appears. A function annotated -> list[float] therefore cannot return a list[int] you built locally; you either annotate the return list[float] and build with float literals, widen the return to a covariant Sequence[float], or add an explicit cast. This is a common source of [return-value] errors during refactors and one more reason read-only positions gravitate toward Sequence.

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. Covariance is sound here precisely because Sequence has no mutation methods in its protocol: with no write position, the element type appears only in return positions (__getitem__ returns T), and a return-only type parameter can safely be covariant. Switch the annotation and every earlier caller type-checks:

Read-only protocol enables covariance Sequence exposes only reading methods so its element type is covariant, whereas MutableSequence adds writing methods that force invariance. Sequence[T] __getitem__ → T (read) __len__, __iter__, index T only in return position covariant → subtypes flow in MutableSequence[T] __setitem__(T) (write) append(T), insert(T) T in argument position too invariant → exact type only
Covariance is a consequence of the read-only protocol: no method takes a T argument, so widening the element type stays sound.
# 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. One import note: before Python 3.9 you had to write from typing import Sequence; since 3.9 (PEP 585) the canonical home is collections.abc, and on 3.9+ importing it from typing triggers ruff UP035 (typing.Sequence is deprecated). The typing alias still works at runtime and is not scheduled for removal, but the linter will nudge you to collections.abc.Sequence. Both mypy and pyright treat the two spellings as the same type. If you need to subscript a collections.abc ABC in a runtime context on Python 3.8, you must either keep the typing alias or add from __future__ import annotations so the subscription is never evaluated — see Mapping vs dict for read-only arguments for the same pattern on mappings.

Concretely, the read-only Sequence[T] family is satisfied by list, tuple, and range, by str (as a Sequence[str]), and by bytes, bytearray, and memoryview (as Sequence[int]), plus anything registered via collections.abc.Sequence.register(...). A frequent surprise is that a numpy.ndarray is not a Sequence to the type checker — NumPy does not register its arrays with collections.abc.Sequence, so a Sequence[float] parameter rejects an ndarray at the call site even though it indexes and iterates fine at runtime; accept numpy.typing.NDArray or a small Protocol there instead. There is also a subtlety with tuples: a homogeneous variadic tuple[int, ...] is a Sequence[int], but a fixed-length heterogeneous tuple[int, str] is not a Sequence[int] — it is a Sequence[int | str], because the element type of a heterogeneous tuple is the union of its members, and mypy will surface that union if you pass one where Sequence[int] is expected.

MutableSequence when you really do mutate

When the function does mutate the argument, say so with MutableSequence[T]. It advertises append, extend, insert, __setitem__, __delitem__, pop, remove, reverse, and slice assignment, and it correctly rejects immutable callers like tuples at the call site rather than at a runtime AttributeError. This is the “just enough capability” middle rung: broader than list[T] (it also admits collections.deque and any custom MutableSequence subclass) but narrower than Sequence[T] (it excludes the read-only tuple, range, and str).

The read-to-write capability ladder Each rung from Iterable to Sequence to MutableSequence to list adds capability and narrows the accepted caller set. more capability required → fewer callers accepted Iterable[T] loop only Sequence[T] + index, len, slice list, tuple, range, str MutableSequence[T] + append, __setitem__ list, deque list[T] concrete type list only
Choose the shortest rung that supports every operation you perform: MutableSequence[T] sits between covariant Sequence[T] and concrete list[T].
# 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. Be aware that MutableSequence is itself invariant, for the same soundness reason as list: it has __setitem__ and append, both of which take a T argument, so MutableSequence[bool] is rejected where MutableSequence[int] is expected. Passing a deque[str] here type-checks because collections.deque is registered as a virtual subclass of MutableSequence; on Python 3.9+ you can subscript it directly (deque[str]), while on 3.8 you would write typing.Deque[str]. If you only ever append (never index or slice), a hand-written Protocol with a single append method expresses the requirement even more precisely — but in practice MutableSequence[T] is the idiomatic annotation for any in-place list rewrite.

Beyond list and deque, the standard library offers two more concrete MutableSequence implementations worth knowing: bytearray is a MutableSequence[int], and array.array behaves like one for its element type. Both support in-place __setitem__ and slice assignment, so a function typed MutableSequence[int] can rewrite a bytearray buffer in place without first copying it to a list. The immutable siblings — bytes, str, tuple, range, and a read-only memoryview — are deliberately excluded, which is precisely the call-site protection you want: passing a bytes object to a function that calls .append() is caught statically as [arg-type] rather than failing at runtime with AttributeError: 'bytes' object has no attribute 'append'.

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. Note one subtlety: list.sort() is a list-specific method, not part of the MutableSequence protocol, so a function that calls items.sort() needs concrete list[T]; a function that would work on any mutable sequence should call the built-in sorted(items) (which returns a new list) or reassign via slice.

Decision tree for the parameter annotation If the function only reads, choose Sequence; if it mutates, choose MutableSequence unless it calls list-specific methods like sort, which require concrete list. Do you mutate the parameter? no yes Sequence[T] iterate, index, len, slice Need list-only method (.sort(), .copy())? no yes MutableSequence[T] list[T]
Read-only work stops at Sequence[T]; mutation reaches MutableSequence[T]; only list-specific methods justify concrete list[T].
# 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 same discipline scales to return types, though the trade-off flips — see Sequence versus list for return types below. When in doubt on a parameter, start at the most permissive Sequence[T] and let the type checker force you narrower: if mypy reports "Sequence[T]" has no attribute "append" [attr-defined], that is the signal to move up exactly one rung to MutableSequence[T], not to jump straight to list[T].

The collections.abc hierarchy also offers finer-grained rungs below Sequence for when you need even less. Collection[T] is sized, iterable, and membership-testable but not indexable; Container[T] supports only in; and Reversible[T] supports reversed(). If a function only checks membership with x in items and never indexes, Collection[T] is more honest than Sequence[T] and additionally accepts set and frozenset, which are not sequences at all. Choosing the narrowest ABC that still covers every operation you perform is simply the general form of the whole Sequence-over-list argument: program to the interface, not the implementation.

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. This is uniquely nasty for str because a string of characters is itself a valid Sequence[str] — the element type and the container type coincide — so there is no type-level distinction for a checker to catch. (The analogous bytes/Sequence[int] confusion works the same way: iterating bytes yields int, so a bytes value satisfies Sequence[int].)

Expected list of tags versus a bare string decomposed into characters A list of whole tags joins as intended, but a bare string passed to the same parameter is decomposed into single characters and joined wrongly, with no type error. intended caller ["prod", "eu"] → "prod,eu" ✓ accidental caller "prod" → "p,r,o,d" (no error) param: Sequence[str] str IS a Sequence[str] element type == container type
Because a str is a sequence of one-character strs, it matches Sequence[str] exactly — the checker sees nothing wrong.
# 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
Runtime vs static analysis No type checker flags 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."

There is no NotStr type, and proposals for a str-excluding sequence type have not been accepted, so the practical choices are: (1) tighten the annotation to list[str] or tuple[str, ...] when you specifically want to bar strings — this is the one place a concrete list[str] parameter earns its keep; (2) add a runtime isinstance(tags, str) guard at the top of the function; or (3) accept the risk when the function is internal and never called with a raw string. The gotcha is worst at API boundaries where callers are untrusted. For collections you only iterate (never index), typing the parameter Iterable[str] does not help — str is an Iterable[str] too — so the runtime guard remains the only reliable defense.

Common mistakes

These five errors account for nearly every Sequence/list annotation problem you will see in review, and each maps to a single operation the body performs; the matrix below summarizes which annotation each operation demands. The rule behind every row is identical: annotate the least-capable interface that still supports what you actually do with the parameter, then let the type checker escalate you one rung at a time when a method turns up missing.

Operation-to-annotation requirement matrix A matrix mapping iterate, index, append, and sort operations to the minimum annotation that supports each: Iterable, Sequence, MutableSequence, or list. operation minimum annotation also accepts for x in p Iterable[T] generators too p[i], len(p), p[a:b] Sequence[T] tuple, range, str p.append(x), p[i]=x MutableSequence[T] deque p.sort(), p.copy() list[T] list only
Each operation dictates the least-restrictive annotation; reading upward shows what extra caller types you gain.
  • list[T] on a read-only parameter. Rejects list[subtype] and every non-list caller with mypy [arg-type] / pyright reportArgumentType. Use Sequence[T].
  • Sequence[T] where you call .append(). mypy reports "Sequence[T]" has no attribute "append" [attr-defined]; pyright reports reportAttributeAccessIssue. Upgrade to MutableSequence[T].
  • Forgetting str is 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]; return list[T] when callers must mutate.
  • Importing Sequence from typing on 3.9+. Works, but ruff UP035 flags the deprecated alias; import from collections.abc per PEP 585.

Underlying all five is one habit worth internalizing: reach for the concrete list[T] on a parameter only when you call a list-specific method or genuinely need to hand back a mutable list; the rest of the time Sequence[T] (read) or MutableSequence[T] (write) both documents intent and maximizes the set of callers your function accepts. The same reasoning generalizes to every container — prefer Mapping over dict, Iterable over list, Collection over set — whenever the body only exercises the smaller interface.

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.

Back to Typing Collections and collections.abc