"Iterable" and "iterator" get used almost interchangeably in casual conversation, but python iterators and iterables are genuinely distinct concepts, and understanding the difference — plus exactly how a for loop actually works underneath — clarifies a lot about Python's design. This article covers the iterator protocol directly, iter next python mechanics, building your own custom iterator class, and how everything connects back to generators, covered in an earlier article in this series.
Introduction: Two Related but Different Concepts
The core distinction
An iterable is any object that can produce an iterator. An iterator is the object actually doing the iterating — tracking position, and producing the next value on demand each time it's asked.
Every list, tuple, string, and dictionary you've worked with throughout this series is an iterable — each has an __iter__() method used specifically to obtain a fresh iterator from it.
numbers = [1, 2, 3] # this is an iterable
iterator = iter(numbers) # this is an iterator, obtained FROM the iterablenumbers itself is the iterable — a list, capable of producing an iterator whenever one is needed. iterator, obtained by calling iter() on it, is a separate object whose specific job is tracking exactly where you currently are in that sequence.
What this article covers
The iterator protocol itself (__iter__/__next__), using iter()/next() manually to see exactly what a for loop does behind the scenes, building your own custom iterator class from scratch, and — tying back to the earlier generators article — precisely how generators relate to everything covered here.
The Iterator Protocol: iter and next
The two required methods
Any object that wants to function as an iterator must implement exactly two methods:
__iter__()— returns the iterator object itself (for an iterator, this is almost always justreturn self).__next__()— returns the next item in the sequence, and raisesStopIterationonce there are no more items left to give.
Demonstrating the machinery manually
numbers = [1, 2, 3]
iterator = iter(numbers) # calls numbers.__iter__() internally
print(next(iterator)) # 1 — calls iterator.__next__() internally
print(next(iterator)) # 2
print(next(iterator)) # 3
print(next(iterator))
# StopIterationiter() and next() are built-in functions that call __iter__() and __next__() on your behalf — this is exactly the same relationship covered in the earlier magic methods article, where built-in functions like len() and operators like + are really just convenient syntax calling a corresponding dunder method underneath.
Why this matters, even though it's usually invisible
A for loop is really just Python calling iter() once, and then calling next() repeatedly, catching StopIteration itself to know when to stop — all handled automatically, invisibly, on your behalf:
numbers = [1, 2, 3]
for n in numbers:
print(n)# What Python is actually doing behind the scenes, made explicit
numbers = [1, 2, 3]
iterator = iter(numbers)
while True:
try:
n = next(iterator)
except StopIteration:
break
print(n)Both versions produce identical output — the plain for loop is simply a considerably more convenient, more readable way of expressing exactly this same underlying iter()/next()/StopIteration mechanism.
Building a Custom Iterator Class
The standard pattern
class EvenNumbers:
def __init__(self, limit):
self.limit = limit
self.current = 0
def __iter__(self):
return self # this object IS its own iterator
def __next__(self):
if self.current > self.limit:
raise StopIteration
value = self.current
self.current += 2
return valueThree pieces working together: __init__ sets up the initial state (current, tracking position, starting at 0), __iter__ returns self (satisfying the requirement that an iterator's __iter__() returns itself), and __next__ computes and returns the next value, incrementing the tracked position, and raising StopIteration once the limit is reached.
Using it
for number in EvenNumbers(10):
print(number)
# 0
# 2
# 4
# 6
# 8
# 10Because EvenNumbers correctly implements both __iter__ and __next__, a for loop works on it exactly the same way it works on a built-in list — Python doesn't distinguish between "built-in" and "custom" iterators at all; it simply calls the same protocol methods either way.
A full worked example: Fibonacci
class FibonacciIterator:
def __init__(self, limit):
self.limit = limit
self.a, self.b = 0, 1
self.count = 0
def __iter__(self):
return self
def __next__(self):
if self.count >= self.limit:
raise StopIteration
value = self.a
self.a, self.b = self.b, self.a + self.b
self.count += 1
return value
for num in FibonacciIterator(8):
print(num, end=" ")
# 0 1 1 2 3 5 8 13self.a and self.b track the two most recent Fibonacci values, updated on every call to __next__, while self.count tracks how many values have been produced so far, compared against self.limit to know exactly when to raise StopIteration.
Iterators use exceptions for control flow
This is genuinely worth calling out explicitly, since it's a slightly unusual design choice at first glance: StopIteration isn't really signaling that something went wrong — it's the standard, expected mechanism iterators use to signal "there's nothing left." Using an exception for perfectly normal, expected control flow (rather than reserving exceptions purely for genuine error conditions) is a deliberate, foundational design choice in Python's iterator protocol, and it's worth recognizing as intentional rather than an odd exception to how exceptions are "supposed" to be used elsewhere.
Iterable vs. Iterator: Why the Distinction Matters
The key insight
An iterable's __iter__() method doesn't actually have to return self — it can return a separate, dedicated iterator object instead. This distinction is precisely what allows multiple independent iterations over the same iterable at the same time, each tracking its own separate position.
A concrete example
class NumberIterator:
def __init__(self, numbers):
self.numbers = numbers
self.index = 0
def __iter__(self):
return self
def __next__(self):
if self.index >= len(self.numbers):
raise StopIteration
value = self.numbers[self.index]
self.index += 1
return value
class NumberCollection:
def __init__(self, numbers):
self.numbers = numbers
def __iter__(self):
return NumberIterator(self.numbers) # a FRESH iterator, every single callcollection = NumberCollection([1, 2, 3])
# Two completely independent iterations, over the same collection
for n in collection:
print(n, end=" ")
print()
for n in collection:
print(n, end=" ")
print()
# 1 2 3
# 1 2 3 — works correctly a second time, since each loop gets its own fresh iteratorNumberCollection is the iterable — every time its __iter__() is called, it creates and returns a brand-new NumberIterator, starting fresh from index = 0. This is precisely why the second for loop above still works correctly, producing the same full sequence again.
The simpler, single-use pattern
Compare this to EvenNumbers and FibonacciIterator from Section 3, where the object serves as both its own iterable and its own iterator (__iter__ returns self, rather than a fresh, separate object):
evens = EvenNumbers(6)
for n in evens:
print(n, end=" ")
print()
for n in evens: # trying to iterate again over the SAME object
print(n, end=" ")
print()
# 0 2 4 6
# — empty! the second loop produced nothing at allThe practical consequence
This is worth internalizing clearly: if an object's iterable and iterator roles are combined into the same object (__iter__ returning self), it can only be iterated through once before being exhausted — its internal position tracking (self.current, in EvenNumbers's case) never resets on its own. A second for loop over the exact same EvenNumbers instance calls __iter__() again, but since it just returns the same, already-exhausted self, the very next __next__() call immediately raises StopIteration again, producing no output at all. This is exactly the kind of confusing "my second loop produced nothing" surprise worth recognizing immediately, rather than spending time debugging something that's actually working exactly as designed — the fix, if you need to genuinely support repeated iteration, is the separate-iterable-and-iterator pattern shown with NumberCollection above.
Infinite Iterators and Connecting Back to Generators
Building an iterator that never stops
An iterator's __next__() method never technically has to raise StopIteration at all — an infinite iterator, producing values forever, is entirely valid:
class InfiniteCounter:
def __init__(self, start=0):
self.current = start
def __iter__(self):
return self
def __next__(self):
value = self.current
self.current += 1
return value # never raises StopIteration — genuinely infiniteUsed carefully, paired with itertools.islice() (covered briefly in the earlier standard library overview article) to take just the first several values:
from itertools import islice
counter = InfiniteCounter(10)
first_five = list(islice(counter, 5))
print(first_five) # [10, 11, 12, 13, 14]islice() pulls exactly the requested number of values from the iterator and stops there — genuinely necessary here, since looping over InfiniteCounter directly with a plain for loop, with no explicit break, would run forever.
Tying back to generators
This is the article's core payoff: a generator function using yield, covered in full in the earlier generators article, is simply a convenient shortcut for writing an iterator, without manually implementing __iter__ and __next__ yourself. Python handles all of that underlying machinery automatically, purely from the presence of yield in a function's body.
# The class-based version — everything covered in this article
class EvenNumbers:
def __init__(self, limit):
self.limit = limit
self.current = 0
def __iter__(self):
return self
def __next__(self):
if self.current > self.limit:
raise StopIteration
value = self.current
self.current += 2
return value
# The generator equivalent — vastly less code, identical external behavior
def even_numbers(limit):
current = 0
while current <= limit:
yield current
current += 2print(list(EvenNumbers(6))) # [0, 2, 4, 6]
print(list(even_numbers(6))) # [0, 2, 4, 6] — identical resultEvery generator is an iterator — a generator object automatically implements both __iter__() and __next__(), correctly satisfying the full iterator protocol without you writing either method yourself. But not every iterator is a generator — the class-based EvenNumbers from Section 3 is a genuine, fully valid iterator, but it's not a generator at all, since it never uses yield.
Practical takeaway: which pattern to reach for
Reach for the full class-based iterator pattern specifically when the object needs additional methods or meaningful state beyond simple iteration — the NumberCollection/NumberIterator split from Section 4, supporting genuinely independent, repeatable iteration, is a good example of something a plain generator function can't express as naturally. Reach for a generator function (yield) for straightforward, one-directional sequences — the overwhelming majority of everyday iteration needs, where you're producing a sequence of values once, lazily, without needing the object to support multiple simultaneous, independent iterations or extra methods beyond just "give me the next value."