Typing Iterable vs Iterator vs Generator Hints

TL;DR — Annotate a return as Iterable[T] when callers only loop over it (possibly more than once); use Iterator[T] when it is single-pass or callers call next() on it; use Generator[Y, S, R] only when send() values or the generator’s return value are part of the contract. Every generator function is an Iterator, so Iterator[T] is the usual, tidiest return annotation for one.

These three names describe increasing specificity, not interchangeable synonyms. Getting the level right tells callers whether they may iterate twice, whether the object is exhausted after one pass, and whether two-way communication is on the table. All three live in collections.abc; typing.Iterable and friends are deprecated aliases (ruff UP035).

Iterable, Iterator, and Generator capability ladder Iterable provides __iter__ and may be re-iterated; Iterator adds __next__ and is single-pass; Generator adds send, throw, and a return value. each level adds capability → Iterable[T] __iter__ loop; re-iterable Iterator[T] + __next__ single-pass Generator[Y,S,R] + send / throw + return R yields Y, two-way
Each level adds capability: Iterable loops, Iterator is single-pass, Generator adds two-way communication and a return value.

iter versus next

An Iterable[T] implements __iter__, which returns a fresh iterator. A list, set, dict, and range are iterables: you can loop over them repeatedly because each for asks for a new iterator. An Iterator[T] implements both __iter__ (returning itself) and __next__, which yields the next item or raises StopIteration. Once an iterator is exhausted, it stays exhausted. This split is the runtime iterator protocol codified in PEP 234: a for statement calls iter(obj) once to obtain an iterator, then calls next() on that iterator until StopIteration is raised.

The iterator protocol call flow A for loop calls iter() once to get an iterator, then calls next() repeatedly until the iterator raises StopIteration. for x in obj: — one iter(), many next() iter(obj) __iter__ Iterator returned once next(it) __next__ → item Stop Iteration loop: next() again until exhausted
A for loop calls iter() once, then next() repeatedly until StopIteration.
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Iterable, Iterator

def read_lines(source: Iterable[str]) -> Iterator[str]:
    for line in source:                 # only needs __iter__ on the input
        stripped = line.strip()
        if stripped:
            yield stripped              # a generator → an Iterator[str]

read_lines accepts an Iterable[str] because it merely loops. It returns an Iterator[str] because the generator it produces is consumed once.

The two ABCs differ by exactly one method, and mypy enforces that difference structurally rather than nominally. You never have to inherit from collections.abc.Iterator; any class that defines __iter__ and __next__ with the right signatures already is an Iterator[T] as far as the checker is concerned.

# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Iterator

class Countdown:
    def __init__(self, start: int) -> None:
        self._n = start

    def __iter__(self) -> "Countdown":
        return self                     # an iterator returns itself

    def __next__(self) -> int:
        if self._n <= 0:
            raise StopIteration
        self._n -= 1
        return self._n + 1

def first(it: Iterator[int]) -> int:
    return next(it)

first(Countdown(3))                     # structural match, no base class needed

In the ABC hierarchy Iterator is registered as a subclass of Iterable, which is why every Iterator[T] is substitutable for an Iterable[T] — the source of both the flexibility and the exhaustion gotcha below. On Python 3.8 these names are still subscriptable only through typing (typing.Iterator[int]); the collections.abc versions became subscriptable in 3.9 under PEP 585, so a 3.8-compatible codebase either imports from typing or reaches for typing_extensions.

Two runtime consequences follow from the protocol. First, iter(x) on an object that defines only __getitem__ (the old sequence protocol) still succeeds, so a handful of objects are iterable without a literal __iter__; mypy does not accept those as Iterable[T], because the ABC’s __subclasshook__ checks for __iter__ alone. Second, both Iterable and Iterator are @runtime_checkable, so isinstance(x, Iterable) works — but it only verifies the method name exists, never the element type, so isinstance(x, Iterator) can never distinguish an Iterator[int] from an Iterator[str].

When Iterable is the right return

Return Iterable[T] when you hand back something callers may loop over more than once — typically a concrete container you build eagerly. Promising Iterable (not Iterator) signals “you can re-iterate this.”

Iterator return versus re-iterable Iterable return Returning Iterator[int] leaves the second loop empty; returning an eagerly built Iterable[int] lets both loops see every element. return Iterator[int] first loop: 1 2 3 4 5 second loop: (empty) single-pass, silently 0 items return Iterable[int] as list first loop: 1 2 3 4 5 second loop: 1 2 3 4 5 re-iterable, both passes full
Same call site, two return types: only the eagerly-built Iterable survives a second pass.
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Iterable

def parse_response(payload: dict[str, list[int]]) -> Iterable[int]:
    return payload.get("ids", [])       # a list: re-iterable, sized

A caller can loop twice over this result. If you had returned Iterator[int], doing so would silently yield nothing the second time.

Iterable[T] is covariant in T, so a function typed -> Iterable[int] may legally return list[bool] as well as list[int], and a caller can bind the result to an Iterable[int] variable without complaint. That covariance is exactly what makes it a safe return abstraction: because the type advertises only reading, there is no append that could inject a wrong-typed element, so widening the element type stays sound — the opposite of list, which is invariant precisely because it is mutable.

There is a design tension worth naming. Returning an abstract Iterable[T] keeps your backing store swappable, but it also hides whether the result is cheap to re-iterate. A caller who sees -> Iterable[int] cannot tell a materialized list from a lazily-built generator — both satisfy the annotation — so “you can re-iterate this” is a convention, not something mypy or pyright enforces. When re-iteration and len() genuinely matter, be concrete and return list[int] or Sequence[int]; when laziness is the point, be honest and return Iterator[int]. Reserve bare Iterable[T] for the middle case: an eagerly-built container whose exact type you would rather not pin down.

# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Iterable, Sequence

def tags_loose() -> Iterable[str]:      # "loop over these"; concrete type unpinned
    return ("draft", "urgent")

def tags_strict() -> Sequence[str]:     # "index, len, re-iterate freely"
    return ["draft", "urgent"]

When you need “re-iterable and sized” but do not want to promise indexing, collections.abc.Collection[T] is the precise middle ground: it is Sized, Iterable, and Container combined, so it guarantees len(), in, and repeated iteration without the __getitem__ that Sequence[T] implies. A set satisfies Collection[int] but not Sequence[int], which makes Collection the honest return type when order is not part of the contract.

The general guideline — accept the most abstract type you can, return the most concrete type that is convenient — is the typing form of the robustness principle, and it is why Iterable appears far more often on parameters than on return annotations.

The single-pass exhaustion gotcha

The most expensive mistake is annotating a one-shot stream as Iterable[T] and re-iterating it. Type checkers cannot catch this — an Iterator is an Iterable, so passing one wherever an Iterable is expected is perfectly valid typing. The second loop just runs zero times.

Iterator exhaustion state machine An iterator advances from fresh through yielding to exhausted; there is no transition back, so a second loop starts already exhausted. fresh not started yielding next() → item exhausted StopIteration next() loops here no arrow back — second loop starts here
An iterator never returns to fresh; a re-run begins already exhausted.
# Python 3.9+, mypy 1.10 — type-checks clean, still a bug
from collections.abc import Iterable

def summarize(values: Iterable[int]) -> tuple[int, int]:
    total = sum(values)      # first pass consumes a generator argument
    count = sum(1 for _ in values)   # second pass: empty if values was an Iterator
    return total, count

summarize(x * x for x in range(5))   # count comes back 0
Runtime vs static analysis Both mypy and pyright accept the double iteration above without complaint, because a generator satisfies Iterable[int]. The emptiness on the second pass is a runtime property of single-use iterators that the type system does not model. If a function must iterate its argument twice, materialize it first (data = list(values)) or annotate the parameter as a re-iterable Sequence[int] so generator callers are at least a deliberate choice.

The trap is wide because so much of the standard library returns single-pass iterators rather than re-iterable containers. zip, map, filter, enumerate, reversed, a generator expression, an open file object, and csv.reader are all Iterators: each is exhausted after one traversal, yet every one of them satisfies Iterable[T]. On Python 3 these builtins became lazy — they were eager, list-returning functions in Python 2 — which is exactly why the annotation and the runtime behaviour can drift apart.

# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Iterable

def report(rows: Iterable[str]) -> None:
    print("count:", sum(1 for _ in rows))    # pass one consumes an Iterator
    for row in rows:                          # pass two: empty for an Iterator
        print(row)

report(map(str.upper, ["a", "b"]))            # type-checks; second loop prints nothing

If a function must traverse its argument more than once, remove the ambiguity at the boundary. Materialize eagerly with rows = list(rows), or duplicate a lazy iterator with itertools.tee(it, n) when buffering the whole thing is unacceptable — note that tee still buffers whatever the slower branch has not consumed, so it is not free. Better still, narrow the annotation: requiring Sequence[T] instead of Iterable[T] turns a generator caller into a deliberate list(...) at the call site rather than a silent zero-length second pass.

# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Iterable

def mean(values: Iterable[float]) -> float:
    data = list(values)                  # materialize once; now safe to reuse
    return sum(data) / len(data)         # two passes over a concrete list

There is no type that means “any iterable except a single-use one”: re-iterability is a runtime property the static system deliberately does not model. Neither mypy nor pyright will ever flag double iteration, so this remains a code-review-and-test concern rather than a type error.

Generator[Y, S, R] when send and return matter

Use the full Generator[YieldType, SendType, ReturnType] only when the extra channels are real. Y is what yield produces, S is what send() injects, and R is the value in the generator’s return. When you never call send() and never use the return value, Iterator[T] is the honest, shorter annotation.

Generator type parameters mapped to their channels YieldType flows out of yield, SendType flows in through send, and ReturnType is delivered by the generator's return statement. Generator[Y, S, R] a running coroutine Y — yield item → values the caller receives S — .send(x) → value the yield returns R — return v rides StopIteration Iterator[T] keeps only Y; drop to it when S and R are None
The three parameters map to distinct channels; collapse to Iterator[Y] when send and return are unused.
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Generator

def running_average() -> Generator[float, float, None]:
    total = 0.0
    count = 0
    value = yield 0.0
    while True:
        total += value
        count += 1
        value = yield total / count     # receives the next value via send()

avg = running_average()
next(avg)                # prime the coroutine
avg.send(10.0)           # 10.0
avg.send(20.0)           # 15.0

For a plain producer with no send(), prefer Iterator[float]; mypy and pyright both accept a generator function annotated that way. Reserve Generator for coroutine-style two-way flow — the send()/throw() machinery that PEP 342 originally added to make generators usable as coroutines.

The three parameters are order-sensitive and easy to misremember, so anchor them to the syntax: the expression value = yield item puts item on channel Y and binds value from channel S, while a bare return result sets channel R. That R value is not thrown away — it surfaces as the argument of the StopIteration a for loop silently swallows, and it is exactly what yield from subgen evaluates to, which is why R is worth typing in delegating generators.

# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Generator

def inner() -> Generator[int, None, str]:
    yield 1
    yield 2
    return "done"                        # this string is channel R

def outer() -> Generator[int, None, None]:
    result = yield from inner()          # result: str — the return value of inner
    print(result)                        # "done"

Two version notes matter. First, prefer collections.abc.Generator, subscriptable since Python 3.9 under PEP 585; typing.Generator still works but is a deprecated alias that ruff flags as UP035, and on 3.8 you must import it from typing or typing_extensions. Second, PEP 696 type-parameter defaults arrived in Python 3.13, giving Generator’s SendType and ReturnType a default of None: Generator[int] is legal on 3.13+ and means Generator[int, None, None]. On 3.12 and earlier you must spell all three, and mypy reports "Generator" expects 3 type arguments, got 1 [type-arg] (pyright: reportInvalidTypeArguments) if you do not.

A related everyday case is contextlib.contextmanager: a @contextmanager function is typed as returning Generator[T, None, None], or the convenience alias Iterator[T], because it yields exactly once and neither sends nor returns a meaningful value. Reaching for the full Generator there over Iterator buys nothing.

The async trio mirror these exactly

Async code has a parallel set in collections.abc: AsyncIterable[T], AsyncIterator[T], and AsyncGenerator[Y, S]. The same decision applies — annotate an async def that only yields as returning AsyncIterator[T], and reserve AsyncGenerator[Y, S] for when asend() is used. Note AsyncGenerator takes only two parameters, not three, because an async generator cannot return a value.

Sync and async iteration types side by side Each sync abstract type has an async twin; the async generator takes two type parameters instead of three because it cannot return a value. sync async iterable Iterable[T] AsyncIterable[T] iterator Iterator[T] AsyncIterator[T] generator Generator[Y, S, R] AsyncGenerator[Y, S] async raises StopAsyncIteration; async generators have no return value (no R)
Every sync type has an async twin; only the async generator drops a parameter — it cannot return a value.
# Python 3.10+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import AsyncIterator

async def stream_events(source: AsyncIterator[bytes]) -> AsyncIterator[str]:
    async for chunk in source:          # consumes with async for
        yield chunk.decode()            # async generator → AsyncIterator[str]

Mixing the sync and async families is a frequent slip: annotating an async def generator with plain Iterator[T] makes mypy report [misc] because the function returns an AsyncGenerator, not an Iterator.

The runtime protocol is the async mirror of PEP 234: async for calls __aiter__ to get an AsyncIterator, then awaits __anext__ until it raises StopAsyncIteration (not StopIteration). Async generators arrived in Python 3.6 via PEP 525; they support asend, athrow, and aclose, but a return value inside one is a SyntaxError, which is the concrete reason AsyncGenerator has no ReturnType slot. On Python 3.13, PEP 696 defaults let you write AsyncGenerator[int] for AsyncGenerator[int, None]; earlier versions require both arguments.

# Python 3.10+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import AsyncIterator

async def tail(source: AsyncIterator[str]) -> AsyncIterator[str]:
    async for line in source:            # consumes with async for
        yield line.rstrip()              # async generator → AsyncIterator[str]

The single most common async slip is a family mismatch — annotating an async def generator with a sync type. Because the body contains yield, Python makes the function an async generator regardless of the annotation, so mypy reports The return type of an async generator function should be "AsyncGenerator" or one of its supertypes [misc]; pyright issues reportGeneralTypeIssues. The reverse — a plain def generator annotated AsyncIterator[T] — fails for the same structural reason. Mirror the context-manager helper too: contextlib.asynccontextmanager wraps an AsyncGenerator[T, None], the async analogue of the Iterator[T]/Generator pairing above.

# Python 3.10+, mypy 1.10 — the mismatch mypy rejects
from collections.abc import Iterator

async def broken() -> Iterator[str]:     # body yields, so it is an async generator
    yield "x"
# mypy: error: The return type of an async generator function should be
#       "AsyncGenerator" or one of its supertypes  [misc]

Common mistakes

Almost every mistake here collapses into one question — what does the caller actually do with the value? — so the fixes follow a short decision tree from “only loops” up to “needs send or the return value.”

Choosing the annotation from caller behaviour Start from what the caller does: only loop picks Iterable, single-pass or next picks Iterator, send or return value picks Generator, and an async body adds the Async prefix. how is the value used? walk the function body only loops (maybe twice) Iterable[T] next() / single-pass Iterator[T] send() or return value Generator[Y, S, R] async def body? prefix Async — and drop R
Pick the least specific type the caller's usage demands; add the Async prefix for an async def.
  • Annotating a producer Generator[T, None, None] out of habit. Verbose and over-specified; mypy accepts Iterator[T] for any generator that only yields. Simplify unless send/return are used.
  • Returning Iterator[T] but expecting callers to loop twice. The second loop is empty at runtime; no analyzer error. Return list[T] or Sequence[T] when re-iteration is part of the contract.
  • Importing from typing. from typing import Iterator triggers ruff UP035 on 3.9+; import from collections.abc.
  • Yielding a value from a function typed -> Iterable[T] and then calling .send() on it. Iterable has no send; mypy reports [attr-defined]. Use Generator when send is needed.
  • Writing Generator[int] on Python ≤ 3.12. The one-argument form only exists from 3.13 via PEP 696 defaults; earlier mypy reports [type-arg]. Spell all three parameters, or drop to Iterator[int].

FAQ

Is every generator an Iterator? Yes. A generator function returns a generator object that implements __iter__ and __next__, so Iterator[T] is always a valid return annotation for one — and usually the clearest.

What does the S in Generator[Y, S, R] do? It types the value that generator.send(value) injects, which appears as the result of the yield expression inside the generator. If you never call send(), set it to None or drop down to Iterator[Y].

Back to Typing Collections and collections.abc