Python Tutorial: Iterators and Iterables - What Are They and How Do They Work?
Based on Corey Schafer's video on YouTube. If you like this content, support the original creators by watching, liking and subscribing to their content.
An iterable provides an iterator via `__iter__`; it does not necessarily implement `__next__` itself.
Briefing
Iterables and iterators in Python boil down to two distinct contracts: an iterable can produce an iterator via its `__iter__` method, while an iterator is a stateful object that yields values one at a time through `__next__` until it raises `StopIteration`. That distinction matters because it determines how loops consume data, how custom classes integrate with Python’s iteration protocols, and how memory-efficient code can be built without materializing entire collections.
A list is the canonical example of an iterable that is not itself an iterator. A `for` loop works by calling `__iter__` on the object to obtain an iterator, then repeatedly calling `__next__` on that iterator. The transcript demonstrates this by showing that calling `next()` directly on a list raises a `TypeError` (“list object is not an iterator”), because lists don’t implement `__next__`. Running `__iter__` on the list returns a “list iterator” object that does implement both `__iter__` and `__next__`. That iterator also behaves as expected when `next()` is called repeatedly: it returns the next element each time, remembers its position internally (its “state”), and eventually raises `StopIteration` once the data is exhausted.
The mechanics of `for` loops become clearer through a manual re-creation of the loop. Instead of relying on Python’s built-in handling, the transcript sketches the equivalent control flow: keep calling `next(iterator)` inside a `try` block, print each item, and break when `StopIteration` occurs. Another key property highlighted is that iterators move only forward. There’s no built-in “rewind” or copying of iteration state; restarting requires creating a new iterator instance.
From there, the tutorial shifts to practical implementation. A custom class, `my_range`, is built to mimic the behavior of Python’s `range`. The class stores `start` and `end` in instance attributes, implements `__iter__` to return an iterator object, and implements `__next__` to advance an internal counter. When the counter reaches the end condition, `__next__` raises `StopIteration`. The result is a class that can be used in a `for` loop and also supports manual stepping with `next()`.
Generators provide the same iteration behavior with less boilerplate. A generator function like `my_range(start, end)` uses `yield` to produce values lazily, automatically creating the iterator protocol methods behind the scenes. The transcript also shows a generator that can run forever by replacing the end condition with `while True`. Used with a `for` loop, this creates an infinite loop—illustrating both the power and the risk of unbounded iterators.
Finally, the memory-efficiency angle is emphasized: iterators fetch one value at a time, so they can handle enormous search spaces that would be impossible to store in a list. The example given is brute-force password cracking, where generating all candidate passwords at once would exhaust memory, but iterating through candidates incrementally can keep memory usage under control even if the computation takes time.
Cornell Notes
Python separates iteration into two roles. An iterable can be looped over because it provides an iterator via `__iter__`. An iterator is stateful: it returns the next value with `__next__` and signals completion by raising `StopIteration`. A list is iterable but not an iterator—calling `next()` on a list raises a `TypeError`, while calling `iter(list)` returns a list iterator that supports `next()`. Custom classes can implement `__iter__` and `__next__` to become both iterable and iterator-like, and generators achieve the same effect automatically using `yield`. This distinction matters for writing correct, efficient, and memory-friendly code.
How can someone tell whether an object is iterable versus an iterator in Python?
Why does `next(nums)` fail for a list, but `next(iter(nums))` works?
What does it mean that iterators have “state,” and how is it observed?
How does a `for` loop behave internally with iterators?
What methods must a custom class implement to behave like an iterable and iterator?
How do generators relate to iterators, and what advantage do they provide?
Review Questions
- What exact methods (`__iter__`, `__next__`) determine whether an object is iterable or an iterator, and what error happens when you call `next()` on a non-iterator?
- In your own words, what triggers `StopIteration`, and how does a `for` loop respond to it?
- How would you modify the `my_range` example to iterate over a descending sequence instead of ascending values?
Key Points
- 1
An iterable provides an iterator via `__iter__`; it does not necessarily implement `__next__` itself.
- 2
An iterator is stateful and advances through values using `__next__`.
- 3
When an iterator is exhausted, `__next__` must raise `StopIteration` to signal completion.
- 4
A `for` loop works by calling `iter(obj)` (or `obj.__iter__()`) and then repeatedly calling `next()` until `StopIteration` occurs.
- 5
Iterators move forward only; restarting iteration requires creating a new iterator instance.
- 6
Custom iteration behavior can be added to classes by implementing `__iter__` and `__next__` (as in `my_range`).
- 7
Generators using `yield` automatically implement the iterator protocol and are often more readable than manual iterator classes.