Understanding the iterator protocol explains why for loops work uniformly across lists, strings, files, and custom objects — it's a simple, two-method contract underneath all of it.
numbers = [1, 2, 3]
it = iter(numbers) # get an iterator from an iterable
print(next(it)) # 1
print(next(it)) # 2
print(next(it)) # 3
# next(it) # ❌ raises StopIteration — exhausted
class CountUp: # a custom iterable/iterator
def __init__(self, limit):
self.limit = limit
self.current = 0
def __iter__(self): # makes CountUp itself iterable
return self
def __next__(self): # defines what happens on each iteration
if self.current >= self.limit:
raise StopIteration # signals "no more items"
self.current += 1
return self.current
for n in CountUp(5): # works exactly like a built-in iterable in a for loop
print(n) # 1, 2, 3, 4, 5
An iterable is any object with an __iter__ method (returning an iterator); an iterator is any object with both __iter__ (returning itself) and __next__ (returning the next value, or raising StopIteration when exhausted). A for loop is essentially syntax sugar over calling iter() once and then next() repeatedly until StopIteration is raised — this is exactly why any class implementing __iter__/__next__ works seamlessly in a for loop, list comprehension, or with sum()/list()/any other function expecting an iterable.
Once an iterator is exhausted, it stays exhausted — calling next() again still raises StopIteration, and re-iterating requires calling iter() again on the original iterable to get a fresh iterator (this is why iterating over a generator, covered next, twice in a row silently produces nothing the second time).