Back to Blog
Python

Python List Typing vs Sequence: When to Use Each

python list typing vs sequence: Learn the difference between using `list` and `Sequence` in Python type hints, and how to choose the right annotation for flexible, mai...

Python typingtype hintsSequenceliststatic analysisAPI design
Illustration comparing a concrete Python list type with an abstract Sequence type, showing flexibility in type annotations.

python list typing vs sequence requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

When you annotate a function parameter as list versus Sequence, you're making a statement about both the expected input and the contract your function promises. The choice affects type checker behavior, API flexibility, and how other developers read your code. This article explains the technical difference and gives concrete guidance for choosing the right annotation.

The Core Difference: list vs Sequence in Type Annotations

list is a concrete built-in type. When you write def process(items: list[str]), you are saying that the argument must be an actual list instance, not a tuple, not a custom sequence, not a string. This is the most restrictive annotation you can use for a sequence-like input.

Sequence (from collections.abc or typing.Sequence) is an abstract base class that defines the interface of a read-only sequence: __len__, __getitem__, __contains__, __iter__, and a few others. When you write def process(items: Sequence[str]), you accept any object that implements that interface, including list, tuple, str, range, bytes, and user-defined sequence classes.

The practical difference is flexibility. Using Sequence in a parameter position says "I only need to read this data in order." Using list says "I need a mutable list, or I will modify it." The type checker enforces these contracts at static analysis time.

Why Sequence Is More Flexible for Read-Only Inputs

Consider a function that computes the average of numbers:

from collections.abc import Sequence from numbers import Real def average(values: Sequence[Real]) -> Real: return sum(values) / len(values)

This function works with a list, a tuple, a range, or any custom sequence. If you annotate values: list[Real], you force callers to convert their tuple or range to a list before calling, which is unnecessary overhead and often a sign of a poorly designed API.

Using Sequence for read-only parameters is a common recommendation in Python type hinting guides. It signals that the function does not mutate the input, which is valuable documentation for maintainers. It also makes the function more reusable across different data structures.

Mutability and Type Safety: What the Type Checker Enforces

If you annotate a parameter as Sequence, the type checker will prevent you from calling mutating methods like append, pop, or __setitem__ on it. For example:

from collections.abc import Sequence def add_item(items: Sequence[str]) -> None: items.append("new") # Type checker error: Sequence has no attribute 'append'

This is a safety net. It catches accidental mutations at development time. If you intend to modify the sequence, you should use list or MutableSequence instead.

On the other hand, if you annotate a parameter as list, you are free to mutate it inside the function. This is appropriate when the function's contract includes modifying the list in place. For example:

def add_default(items: list[str]) -> None: items.append("default")

Here, the caller knows that their list may be changed. The type checker will not complain about the mutation.

Covariance and Invariance: How Type Checkers Treat These Types

Type checkers like mypy and Pyright treat list as invariant and Sequence as covariant. This has important implications for where you can assign one type to another.

Invariance means that list[Derived] is not a subtype of list[Base], even if Derived is a subtype of Base. This is because a list allows mutation, and mutation could break type safety. For example, if list[Derived] were assignable to list[Base], you could append a Base instance to a list that is actually list[Derived], violating the type invariant.

Covariance means that Sequence[Derived] is a subtype of Sequence[Base]. Since Sequence is read-only, this is safe. You can pass a Sequence[Derived] where a Sequence[Base] is expected.

This difference is visible in function signatures. Consider:

from collections.abc import Sequence def print_all(items: Sequence[object]) -> None: for item in items: print(item) print_all([1, 2, 3]) # OK: list[int] is a Sequence[object] due to covariance def append_one(items: list[object]) -> None: items.append(1) append_one([1, 2, 3]) # OK: list[int] is not assignable to list[object] due to invariance

The second call will produce a type error in strict type checkers. To avoid this, you would need to annotate the parameter as Sequence[object] if you only read it, or use a generic type with TypeVar if you need to preserve the element type.

Practical API Design: Choosing the Right Annotation for Parameters and Returns

For function parameters, the rule of thumb is:

  • Use Sequence when the function only reads the input and does not modify it.
  • Use list when the function needs to mutate the input (append, pop, etc.) or when the input must be a list for a specific reason (e.g., you rely on list-specific methods).
  • Use MutableSequence from collections.abc when you need a more general mutable sequence interface, but list is usually the concrete type you expect.

For return types, the choice is different. If your function returns a new collection, you can choose the most specific type that is useful to the caller. Returning list is common because it is the default mutable sequence. Returning Sequence is appropriate when you want to allow the implementation to return a tuple or a custom immutable sequence, but it also restricts the caller from mutating the result. This can be a deliberate design choice to enforce immutability.

from collections.abc import Sequence def get_names() -> Sequence[str]: return ("Alice", "Bob") # tuple is a Sequence

If you later change the implementation to return a list, the annotation still holds. This gives you freedom to change internals without breaking the public API contract.

Performance and Runtime Behavior: What the Annotation Does Not Change

Type annotations have no effect on runtime behavior in Python. They are not enforced at runtime and do not change how the interpreter executes the code. This means that using Sequence instead of list does not make your code faster or slower. The performance characteristics are determined by the actual object passed, not the annotation.

However, the choice of annotation can indirectly affect performance through the types you accept. For example, if you annotate a parameter as Sequence, a caller might pass a tuple instead of a list. Tuples are often more memory-efficient and faster to iterate over because they are immutable and can be optimized by the interpreter. But this is a property of the caller's choice, not the annotation itself.

What the annotation does affect is static analysis and tooling. Type checkers can catch errors early, and IDEs can provide better autocomplete and refactoring support. Using Sequence for read-only parameters can also prevent accidental mutations, which can lead to fewer runtime bugs.

Common Pitfalls and Edge Cases with Sequence and list

One common mistake is using Sequence when you actually need to index with negative indices or slice the input. While Sequence supports these operations, the type checker will see them as valid. The real pitfall is when you assume the input is a list and call a method like sort() or reverse(), which are not part of the Sequence interface. The type checker will catch this, but it can be confusing if you are used to working with lists.

Another edge case is strings. str is a Sequence[str] (each character is a string of length 1). If you annotate a parameter as Sequence[str], you can pass a string, which might be unexpected. If your function is meant to handle only collections of strings, you might want to exclude str explicitly. This is a common source of subtle bugs. For example:

from collections.abc import Sequence def join_all(items: Sequence[str]) -> str: return "".join(items) join_all("hello") # This works, but is it intended?

If you want to exclude strings, you can use Sequence[str] and add a runtime check, or you can use a TypeVar with a bound to restrict the element type more precisely. The type checker alone cannot prevent a string from being passed as a Sequence[str] because a string is indeed a sequence of strings.

Finally, note that typing.Sequence is deprecated in Python 3.9 in favor of collections.abc.Sequence. While typing.Sequence still works for backward compatibility, new code should use collections.abc.Sequence for type annotations. The same applies to other ABCs like MutableSequence. This is a maintainability concern: using the modern import avoids warnings and aligns with the standard library's structure.

When you need to accept both mutable and immutable sequences without mutating them, Sequence is the right choice. When you need to mutate, use list or MutableSequence. By making this distinction in your type hints, you communicate the contract clearly and let the type checker enforce it, leading to more robust and maintainable code.

python list typing vs sequence: Practical Usage and Code Exa | RYUSLOG DEV