Skip to content
BytePatterns

Generator vs Iterator vs Iterable in Python: One Protocol

7 min readBytePatterns

Generator vs iterator vs iterable in Python: __iter__ hands out iterators, __next__ walks once, yield writes both for you. Laziness, exhaustion, memory.

Generator vs iterator vs iterable in Python in one line: an iterable is anything that can hand out an iterator when you call iter() on it, an iterator is the object that actually walks, handing out one item per next() until it raises StopIteration, and a generator is an iterator you write as a function with yield (or as a generator expression) instead of as a class. The three are not rivals but layers of one protocol: lists and your own containers are iterables, every for loop runs on an iterator, and generators are the shortest way to produce one. The protocol below is from the Python documentation, as of October 2026, and every example runs on Python 3.9.

TermWhat it hasWalk it twice?
iterableiter, returning a new iteratoryes, each iter() starts over
iteratornext, and iter returning itselfno, one pass, then StopIteration forever
generatoran iterator built by a yield function or a generator expressionno, it is an iterator

The problem it solves

You want for value in my_list: to work on your own linked list, tree or file reader, ideally without building a full Python list first. To do that you need to know which object holds the data, which object remembers where the walk is, and what happens when the walk ends. Mixing those up gives the classic bugs: a loop that silently does nothing the second time, or a nested loop that pairs only the first item.

The intuition

Separate the collection from the cursor.

  • An iterable is the collection. It defines __iter__, which returns a fresh iterator each time. A list, a dict, a string and a well-written LinkedList are iterables, and you can loop over them as often as you like.
  • An iterator is the cursor. It defines __next__, which returns the next item or raises StopIteration, and __iter__, which returns itself so that iterators can be used wherever an iterable is expected. The documentation adds the rule that matters: once __next__ has raised StopIteration, it must keep doing so. An exhausted iterator stays exhausted.
  • A generator is an iterator you do not have to write by hand. Calling a function that contains yield runs none of its body; it returns a generator object. Each next() runs the body up to the next yield, hands out that value and freezes the local variables until the next call. When the function returns, the generator raises StopIteration. The documentation points out the standard trick: if a container's __iter__ is written as a generator, it automatically returns a new iterator on every call.

A for loop is this protocol in a few lines: call iter() on the expression once, then call next() until StopIteration, which the loop catches and treats as the end. yield from other delegates: it re-yields everything other produces, so one generator can chain several walks.

Laziness is the payoff. A generator computes each value when asked, so its memory does not depend on how many values it will produce, and it can describe sequences that would never fit in a list. The cost is the other side of the same coin: no len(), no indexing, no second pass, and errors surface only when the bad value is reached.

When to use which

  • Your class holds data people will loop over: make it an iterable by writing __iter__ as a generator.
  • You need a stateful cursor with extra methods, such as peek() or skip(): write an iterator class with __next__.
  • One pass over a large or endless stream, such as file lines, pages from an API or a running total: a generator function or expression.
  • You need random access, len(), or several passes: materialise a list once.
  • You need to add and remove at both ends while walking: that is a buffer, not an iterator; use a deque.

Watch it run

The lesson's animation is one pointer and one rule: keep reassigning it to node.next until it falls off the end. It searches 4 → 8 → 15 for 15, comparing each value and moving on, and returns index 2; it notes that jumping to the middle would already cost a walk, so halving buys nothing. Then it searches for 9: the same walk with no early exit, until node becomes empty, the list's built-in stop sign, and the loop returns -1. A generator-based __iter__ is exactly that walk with a yield where the comparison was. The empty pointer at the end becomes StopIteration, and the search moves out into whatever loop consumes the values.

Traversal and Search

Step 1 of 11

Traversal is one pointer and one rule: keep reassigning it to node.next until it falls off the end.

The same interactive animation as the lesson — step through it with the controls.

The code

A linked list whose __iter__ is a generator, next to the same walk written as an iterator class. One restarts on every loop; the other is used up after one:

class Node:
    def __init__(self, v):
        self.value, self.next = v, None

class LinkedList:                        # an iterable: it can hand out iterators
    def __init__(self, values):
        self.head = None
        for v in reversed(values):
            node = Node(v)
            node.next, self.head = self.head, node

    def __iter__(self):                  # a generator function: each call, a fresh walk
        node = self.head
        while node is not None:
            yield node.value
            node = node.next

class Walker:                            # the same walk as a hand-written iterator
    def __init__(self, head):
        self.node = head
    def __iter__(self):
        return self                      # an iterator is its own iterator
    def __next__(self):
        if self.node is None:
            raise StopIteration
        value, self.node = self.node.value, self.node.next
        return value

chain = LinkedList([4, 8, 15])
it = iter(chain)                         # what a for loop calls first
print(type(it).__name__, iter(it) is it, iter(chain) is it)   # generator True False
print(next(it), next(it), next(it))      # 4 8 15
try:
    next(it)
except StopIteration:
    print("exhausted")                   # exhausted
print(list(chain), list(chain))          # [4, 8, 15] [4, 8, 15]  <- the iterable restarts
w = Walker(chain.head)
print(list(w), list(w))                  # [4, 8, 15] []  <- the iterator does not

Next, what laziness buys and what it costs: memory, silent exhaustion, in consuming items, yield from, and an error that waits until it is reached. The sizes are for 64-bit CPython 3.9:

import sys

squares_list = [n * n for n in range(1_000_000)]     # built now, all of it
squares_gen = (n * n for n in range(1_000_000))      # built on demand, one at a time
print(sys.getsizeof(squares_list), sys.getsizeof(squares_gen))   # 8448728 112
print(sum(squares_gen) == sum(squares_list))         # True
print(sum(squares_gen))                              # 0  <- already exhausted, no error

def evens(it):
    for x in it:
        if x % 2 == 0:
            yield x

g = evens(chain)
print(8 in g, 4 in g, list(g))          # True False []  <- 'in' consumed 4 on the way to 8

def walk_both(a, b):
    yield from a                         # delegate: re-yield everything a produces
    yield from b

print(list(walk_both(chain, LinkedList([16, 23]))))  # [4, 8, 15, 16, 23]

lazy = (1 / x for x in [1, 0])           # nothing runs yet
print(next(lazy))                        # 1.0
try:
    next(lazy)
except ZeroDivisionError as e:
    print("raised on demand:", e)        # raised on demand: division by zero

The list's container alone is about 8 MB, before counting the integers it points to; the generator object stays the same small size whatever the range. The second sum returned 0 rather than raising, which is how exhaustion bugs hide. Last, a seeded check that the generator walk, the lesson's while loop and a plain list agree, that nested loops over the iterable work, and that a search written over the iterator matches list.index:

import random

def walk_loop(head):                     # the lesson's walk, collecting values
    out, node = [], head
    while node is not None:
        out.append(node.value)
        node = node.next
    return out

def find(chain, target):                 # search written over the iterator
    return next((i for i, v in enumerate(chain) if v == target), -1)

rng = random.Random(2)
ok = True
for _ in range(3000):
    values = [rng.randint(0, 9) for _ in range(rng.randint(0, 12))]
    chain = LinkedList(values)
    ok &= list(chain) == walk_loop(chain.head) == values
    ok &= [(a, b) for a in chain for b in chain] == [(a, b) for a in values for b in values]
    t = rng.randint(0, 10)
    ok &= find(chain, t) == (values.index(t) if t in values else -1)
print(ok)                                # True

shared = Walker(LinkedList([1, 2]).head) # one iterator used in a nested loop
print([(a, b) for a in shared for b in shared])   # [(1, 2)]  <- the inner loop drained it

The nested loop over chain worked because each for called __iter__ and got its own generator. The same loop over one Walker produced a single pair: the inner loop drained the only cursor.

The complexity

  • A full walk: O(n) time for all three; a generator adds a small constant per item for resuming its frame.
  • Memory: a list holds all n items, O(n); an iterator or generator holds only its position, O(1), plus whatever the underlying collection already uses.
  • First item: a generator yields it after O(1) work; building a list first costs O(n) before you see anything, which matters for early exits like find.
  • Random access and len(): not available on iterators; list(it) costs O(n) time and memory.

Where it goes wrong

  • Looping over a generator twice. The second loop runs zero times without an error. Materialise with list() or call the generator function again.
  • Making a container its own iterator. If __iter__ returns self and the cursor lives on the container, nested loops and concurrent walks share one position. Return a new iterator, and a generator makes that automatic.
  • len() on a generator. It raises TypeError; there is no length until the walk is done.
  • x in gen as a check. It consumes items up to the match, so later code sees a shorter stream.
  • Assuming errors appear at creation. A generator expression runs nothing until asked, so a bad value fails wherever it is first consumed, often far from where it was written.
  • Changing a linked list during a walk. The generator follows live next pointers; unlinking the current node mid-walk can end the walk early or skip values.

When it shows up in interviews

As a Python fundamentals question ("what is the difference between an iterator and an iterable?", "what does yield do?") and as a follow-up to any linked-list or tree problem: "make your class work in a for loop" or "return the values lazily". In-order traversal of a binary tree with yield from on both subtrees is a favourite. The walk itself, including the off-by-one traps of search, is in linked list traversal and search in Python, and when you need a buffer rather than a one-way stream, deque vs list for queues covers the right structure. The pointer walk is the Traversal and Search lesson.

How to say it in an interview

"An iterable is anything with __iter__ that gives me a fresh iterator; an iterator has __next__, returns itself from __iter__, and raises StopIteration when it is done, after which it stays done. A for loop calls iter() once and then next() until StopIteration. A generator is the easy way to write an iterator: a function with yield whose state is frozen between calls. For a linked list I write __iter__ as a generator that walks from the head, so the list stays reusable, nested loops work, and memory stays O(1) per walk."

Frequently asked questions

What is the difference between an iterator and an iterable in Python?

An iterable can produce iterators: calling iter() on it returns a new one each time, so it can be looped over repeatedly. An iterator is the cursor itself: next() returns one item at a time until StopIteration, and once exhausted it stays exhausted. Every iterator is also an iterable that returns itself.

Is a generator an iterator in Python?

Yes. A generator object, created by calling a function that contains yield or by a generator expression, implements both __iter__ and __next__. It is a single-pass iterator: it computes values lazily, one per next() call, and raises StopIteration when the function body finishes.

Why does my generator return nothing the second time?

A generator is an iterator, and iterators are single-pass. The first loop consumed every value; the second calls next() on an exhausted generator, gets StopIteration immediately and runs zero times. Store the results in a list, or call the generator function again to get a fresh generator.

Sources