The two methods that define an iterator
In Python, anything you can put on the right side of for x in ... is an iterable. The mechanism is simple: an iterable has an __iter__ method that returns an iterator, and an iterator has a __next__ method that returns the next value (or raises StopIteration when there are no more). Lists, tuples, dicts, files, ranges, generator expressions — all support this protocol. Once you understand the two methods, the entire iteration machinery in Python becomes transparent.
Iterable vs. Iterator — the distinction
An iterable can be iterated. An iterator is the thing actually doing the iterating. my_list is iterable; iter(my_list) returns an iterator over it. A for loop calls iter() on its target to get the iterator, then calls next() on the iterator until StopIteration. Most iterators are also iterable — they return themselves from their own __iter__ — but iterables aren't always iterators.
The for-loop, demystified
for x in seq: is, under the hood, almost exactly: it = iter(seq), then a while True: with x = next(it) and a try/except StopIteration: break. Knowing this, you can step through iteration manually when debugging, build your own iterators that compose with for seamlessly, and reason about why some bugs (like iterator exhaustion) happen.
StopIteration is raised, calling next() on the same iterator raises StopIteration again forever. If you wrap the same iterator in two for loops, the second loop runs zero iterations. This is the most common source of "why is my second pass empty?" bugs.
Building a custom iterator
Implement __iter__ (returning self, by convention) and __next__ (returning the next value or raising StopIteration). Now for x in my_object works. We'll see in lesson 2 that generator functions provide a vastly easier way to do this — but understanding the protocol first means you can read the source of any standard library iterator and know what's happening.