Skip to content
← All articles

Iterator, Iterable, Generator, Sequence

Four ABCs that people pick by vibes. Each one is a promise about what your function will do to the argument — and getting it wrong produces a bug that has no exception attached to it.

Most people choose between Iterable, Iterator, Sequence and list by asking “what will callers pass me?”. That is the wrong question, and it is why the answer usually ends up being list.

The right question is: what am I going to do to this argument? The annotation is a promise about the body, not a description of the caller.

The four promises

You write You are promising
Iterable[T] I will iterate it — possibly zero times, at most once
Iterator[T] I will call next() on it; I know it is single-shot and I own its position
Collection[T] I will iterate it, and I may take len() and use in
Sequence[T] I may index it, slice it, reverse it, and traverse it more than once
Generator[Y, S, R] I may send() into it and I care about its return value

Read down that list and notice it is ordered by how much you are demanding of the caller. Iterable demands almost nothing, so almost everything satisfies it. Sequence demands a lot, so a generator cannot be passed at all — which is exactly what you want if your body indexes.

The structural definitions, for reference:

  • Iterable requires __iter__.
  • Iterator requires __iter__ and __next__. Every iterator is an iterable, and returns itself from __iter__.
  • Collection is Sized + Iterable + Container: __len__, __iter__, __contains__.
  • Sequence adds __getitem__ with integer and slice support, plus index, count, __reversed__.

Rule one: accept broad, return narrow

def parse(lines: Iterable[str]) -> Iterator[Record]:
    for line in lines:
        yield Record.from_line(line)

The parameter is Iterable — the broadest thing the body can honestly work with, so a list, a tuple, a file object, a generator, a dict.keys() view and a set all work. The return is Iterator — the narrowest honest description of what comes back, so the caller learns two things: it is lazy, and it is single-shot.

Annotating the return as Iterable[Record] throws away that second fact and callers will read it twice. Annotating the parameter as Iterator[str] refuses lists for no reason whatsoever.

💡If Iterator is a subtype of Iterable, why is returning Iterator "narrower and better" while accepting Iterator is "needlessly restrictive"? Isn't that the same relationship twice? click to reveal

It is the same relationship, applied in opposite directions — which is exactly what variance means for function types.

A function is a provider of its return value and a consumer of its parameters. Being more specific about what you provide is a strengthening of your guarantee: any caller who could handle an Iterable can also handle an Iterator, so narrowing the return breaks nobody and tells them more.

Being more specific about what you consume is a weakening of your applicability: callers who have a list can no longer call you. That is the covariance/contravariance rule for callables, and “accept broad, return narrow” is just its everyday phrasing. It is also why Callable[[Iterable[str]], Iterator[R]] is a subtype of Callable[[list[str]], Iterable[R]], not the other way around.

The exception is when the narrowness is load-bearing on the parameter — if your body genuinely calls len(), demand Sized; if it genuinely indexes, demand Sequence. Demanding less than your body needs is not generosity, it is a type error waiting to be found at runtime.

Rule two: Iterable means at most one pass

This is the promise people break without noticing, because breaking it raises nothing.

def summarise(rows: Iterable[Row]) -> Summary:
    total = sum(r.amount for r in rows)      # pass 1
    count = sum(1 for _ in rows)             # pass 2: zero for a generator
    return Summary(total=total, mean=total / count)   # ZeroDivisionError, eventually

With a list argument this works and every test passes. With a generator argument the second pass sees an exhausted iterator, count is 0, and the failure surfaces as a division error that has nothing to do with the actual bug.

If you need two passes, say so:

def summarise(rows: Sequence[Row]) -> Summary:      # now a generator is a type error

or materialise on purpose, at the top, with the reason written down:

def summarise(rows: Iterable[Row]) -> Summary:
    # Two passes; caller may hand us a generator, so buffer once.
    buffered = list(rows)

Both are fine. What is not fine is Iterable in the signature and two passes in the body.

Rule three: str satisfies everything

def render(names: Iterable[str]) -> str:
    return ", ".join(names)

render("alice")     # -> "a, l, i, c, e"

No type error. str is an Iterable[str], and it is a Sequence[str], and it is a Collection[str] — a string is a sequence of one-character strings, so it satisfies every container ABC parameterised by str. bytes has the same problem shaped slightly differently: it is Iterable[int].

There is no annotation that says “a collection of strings but not a string”. The practical defences are: make the parameter keyword-only so the call site reads render(names=...) and the mistake is visible, or take a list[str] when you genuinely want a container, or wrap the type in a NewType. Mostly, know that it happens.

💡Would Sequence[str] have caught the render("alice") bug? click to reveal

No. str is a Sequence[str] too — it has __len__, __getitem__, slicing, index and count, all of which return or accept str. Every container ABC in the hierarchy is satisfied.

This is not a hole in the type system so much as a consequence of Python’s decision that there is no character type. str[0] is a str, so str is genuinely a sequence of str, and the type checker is telling the truth.

What does catch it: list[str] as the parameter type (invariant, so str is not a list[str]), or tuple[str, ...], or a runtime guard. Some teams keep a tiny helper:

def _not_a_string(value: Iterable[str]) -> Iterable[str]:
    if isinstance(value, str):
        raise TypeError("expected a collection of strings, got a single string")
    return value

which is ugly and, at boundaries that take user configuration, has repeatedly been worth it.

Generator and its three parameters

Generator[Yield, Send, Return]:

def counter() -> Generator[int, str, bool]:
    total = 0
    while True:
        command = yield total       # `command` is the Send type: str
        if command == "stop":
            return True             # the Return type: bool
        total += 1

Almost no generator uses send() or a return value, so almost every generator should be annotated Iterator[T] and be done with it. Write Generator when the extra parameters are real; otherwise the two Nones are noise that hides which one is which.

The async pair drops one parameter: AsyncGenerator[Yield, Send], because an async generator cannot return a value.

A version note that matters if you ship a library. PEP 696 (3.13) gave type parameters defaults, and 3.13’s typing applied them here — in 3.14’s source the line reads Generator = _alias(collections.abc.Generator, 3, defaults=(types.NoneType, types.NoneType)). So Generator[int] now means Generator[int, None, None].

The trap is that the short form type-checks regardless of the python_version you configure, because the defaults live in the stubs, not in the interpreter you are targeting. mypy will happily sign off on Generator[int] in a package whose metadata claims requires-python = ">=3.12". At runtime on 3.12, typing.Generator[int] raises TypeError: Too few arguments — while collections.abc.Generator[int] does not, because types.GenericAlias never checks arity. So whether you get a clean error or silent divergence depends on which import you used. If you support 3.12, write the full three-parameter form. Same reasoning for AsyncGenerator[int].

Import from collections.abc

typing.Iterable, typing.Iterator, typing.Sequence and friends have been deprecated since 3.9 in favour of the collections.abc originals. Removal is not currently planned, so nothing will break — but new code should not use them.

Note which tool enforces this: mypy --strict does not flag from typing import List, Dict, Optional, not even with --enable-error-code deprecated. Modernisation is entirely ruff’s job (UP035, UP006, UP007). If your CI runs mypy and no linter, deprecated aliases will accumulate forever and the type checker will never say a word.

Choosing, in one paragraph

Start at Iterable[T] for every parameter you only loop over once. Move to Collection[T] the moment you need len() or in. Move to Sequence[T] the moment you index, slice, or need a second pass. Use Iterator[T] as a parameter only when you deliberately want the caller to hand over ownership of a position — a tokeniser’s cursor, a merge join’s cursor — and say so in the docstring, because the type alone cannot express “and do not use this afterwards”. Return Iterator[T] from anything lazy. Reach for list[T] in a signature only when mutation is part of the contract.