Iterators and Iterables in Python

"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 iterable

numbers 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 just return self).

  • __next__() — returns the next item in the sequence, and raises StopIteration once 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))
# StopIteration

iter() 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 value

Three 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
# 10

Because 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 13

self.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 call
collection = 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 iterator

NumberCollection 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 all
The 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 infinite

Used 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 += 2
print(list(EvenNumbers(6)))    # [0, 2, 4, 6]
print(list(even_numbers(6)))    # [0, 2, 4, 6] — identical result

Every 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."