Back to Blog
Python

Python Iterable Type Hint: Choosing the Right Type

python iterable type hint: Learn how to annotate iterables in Python with typing.Iterable, Iterator, and Sequence. Understand when to use each type and avoid common mi...

type hintstypingiterablesiteratorspython typing
Illustration of Python iterable type hints showing a list and a generator with type annotations

When you write a function that accepts a list, you might be tempted to annotate the parameter as list[int]. That works, but it's often too restrictive. The function may only need to iterate over the input, not index it or modify it. The python iterable type hint that matches that intent is Iterable[int] from the typing module. Using the right iterable type hint makes your API more flexible and communicates exactly what the function does with its input.

Why the Most Specific Type Is Not Always the Best

Consider a function that sums the squares of a collection of numbers. If you annotate the parameter as list[int], the caller must pass a list. A tuple, a set, a generator, or a custom object implementing __iter__ would all be rejected by a static type checker, even though the function never relies on list-specific behavior.

def sum_squares(numbers: list[int]) -> int: return sum(n * n for n in numbers)

This function only iterates over numbers. The list annotation is an unnecessary constraint. A better annotation is Iterable[int]:

from collections.abc import Iterable def sum_squares(numbers: Iterable[int]) -> int: return sum(n * n for n in numbers)

Now the function accepts any iterable of integers, including generators, sets, and tuples. The intent is clearer: the function only needs to read the elements once, in order, and does not require indexing or random access.

The Core Types: Iterable, Iterator, Sequence, and Their Differences

Python's typing module provides several abstract types for collections and iteration. The three most common are Iterable, Iterator, and Sequence. They differ in the operations they guarantee.

TypeGuaranteesTypical use
Iterable[T]Can be iterated with for; has __iter__Parameters that only need to be read sequentially
Iterator[T]Can be iterated once; has __next__; also __iter__ returning itselfReturn types for generators or custom iterator objects
Sequence[T]Supports indexing, slicing, length; has __len__ and __getitem__Parameters that need random access or repeated iteration

Iterable is the most permissive. It does not guarantee that you can iterate more than once, but in practice most iterables are re-iterable. Iterator is a one-shot iterable: after you consume it, it is exhausted. Sequence is a re-iterable, indexable collection like list, tuple, and str.

Using Iterable for Function Parameters

When a function only needs to loop over its input, annotate the parameter as Iterable[T]. This is the most flexible contract that still guarantees type safety. It tells the caller: "I will iterate over this, but I won't modify it or access elements by index."

from collections.abc import Iterable def process_items(items: Iterable[str]) -> None: for item in items: print(item.upper())

This function works with a list, a tuple, a set, a generator, or any custom iterable. If you later change the implementation to use indexing, you must also change the annotation to Sequence[str] to reflect the new requirement.

One subtlety: Iterable does not guarantee re-iterability. If your function needs to iterate over the input more than once, you should either document that requirement or accept a Sequence instead. For example, if you need to compute both the sum and the count of elements, you might iterate twice:

def average(numbers: Iterable[float]) -> float: total = sum(numbers) count = sum(1 for _ in numbers) # This will be 0 if numbers is a generator! return total / count

If numbers is a generator, the first sum consumes it, so the second iteration yields nothing. In such cases, you should accept a Sequence or materialize the iterable inside the function. The annotation should reflect the actual requirement.

Using Iterator for Return Types and Generator Functions

When a function returns a generator or a custom iterator, annotate the return type as Iterator[T]. This is more precise than Iterable[T] because it signals that the result can be iterated only once.

from collections.abc import Iterator def countdown(n: int) -> Iterator[int]: while n > 0: yield n n -= 1

A generator function automatically returns an iterator, so Iterator[int] is the correct annotation. If you used Iterable[int], you would be overstating the contract: the caller might expect to be able to iterate multiple times, which is not true for a generator.

For custom iterator classes, you can also use Iterator[T] as the base class:

from collections.abc import Iterator class Countdown(Iterator[int]): def __init__(self, n: int): self.n = n def __next__(self) -> int: if self.n <= 0: raise StopIteration self.n -= 1 return self.n

This class satisfies the iterator protocol and is correctly annotated.

Common Mistakes: Overly Specific Types and Variance Issues

One frequent mistake is using list[T] when Iterable[T] is sufficient. This forces callers to convert their data to a list, which can be wasteful and reduces flexibility. Another mistake is using Iterable[T] for a function that actually needs to index elements; that should be Sequence[T].

Variance is another subtle issue. Iterable is covariant in its type parameter: Iterable[int] is a subtype of Iterable[float] because every integer is a float. This is safe because iteration only produces values. Sequence is also covariant. Iterator is covariant as well. However, a mutable collection like list is invariant: list[int] is not a subtype of list[float]. This means a function expecting list[float] cannot accept a list[int], even though it might be safe in practice. Using Iterable[float] avoids this issue.

def total(values: Iterable[float]) -> float: return sum(values) ints: list[int] = [1, 2, 3] total(ints) # OK because Iterable is covariant

If you annotated values as list[float], this call would fail a static type check. Prefer Iterable when you only need to read elements.

Runtime Behavior and Performance Considerations

Type hints are not enforced at runtime in Python. They are used by static type checkers and IDEs, and they do not affect performance. The Iterable annotation is just metadata; it does not wrap the object or add any iteration overhead.

However, the choice of annotation can influence runtime behavior indirectly. If you annotate a parameter as Iterable but internally convert it to a list, you add memory and time overhead. Conversely, if you accept a Sequence but only iterate, you might reject valid inputs. The annotation should match the actual usage to avoid unnecessary conversions.

For example, if a function needs to iterate twice, and you annotate it as Iterable, you might be tempted to call list(items) inside. That is a runtime cost. A better design is to accept Sequence from the start, or to document that the input must be re-iterable and use Iterable only if you are sure the caller will pass a re-iterable object. In general, the more specific the annotation, the more runtime guarantees you get, but also the more restrictions you place on callers.

Compatibility: typing vs collections.abc and Python Version Support

Before Python 3.9, the recommended way to import Iterable, Iterator, and Sequence was from typing. Since Python 3.9, the standard library provides these classes in collections.abc, and they support subscripting with type parameters. For example:

from collections.abc import Iterable def f(data: Iterable[int]) -> None: ...

This works in Python 3.9 and later. In Python 3.8 and earlier, you must use typing.Iterable and typing.Iterator:

from typing import Iterable def f(data: Iterable[int]) -> None: ...

typing.Iterable is still available in newer versions, but collections.abc is preferred for new code because it avoids duplication and aligns with the actual runtime classes. If you need to support both old and new Python versions, you can use a conditional import or rely on typing for backward compatibility.

Another compatibility note: when using collections.abc.Iterable as a base class for custom iterables, you must implement __iter__. For Iterator, you must implement __next__ and __iter__. The type checker will enforce these requirements.

Practical Example: Building a Type-Safe Pipeline

Let's combine these concepts in a small pipeline that reads numbers from an iterable, filters even numbers, and squares them. The functions use appropriate iterable type hints to remain flexible.

from collections.abc import Iterable, Iterator def even_numbers(numbers: Iterable[int]) -> Iterator[int]: for n in numbers: if n % 2 == 0: yield n def squared(numbers: Iterable[int]) -> Iterator[int]: for n in numbers: yield n * n def process(numbers: Iterable[int]) -> list[int]: return list(squared(even_numbers(numbers)))

even_numbers accepts any iterable and returns an iterator. squared does the same. process accepts any iterable and returns a concrete list. This design lets callers pass a generator, a tuple, or a list, and the pipeline works without unnecessary conversions. The type hints clearly state that even_numbers and squared are one-shot iterators, while process returns a reusable list.

If you later need to make process lazy, you can change its return type to Iterator[int] and return a generator expression instead of a list. The annotations guide you to make that change consistently.

Choosing the right python iterable type hint is about matching the annotation to the actual contract. Use Iterable for parameters that only need to be iterated, Iterator for one-shot returns, and Sequence when you need indexing or repeated iteration. This keeps your API flexible, avoids unnecessary runtime conversions, and helps static type checkers catch misuse early.

python iterable type hint: Practical Usage and Code Examples | RYUSLOG DEV