Python Lesson 25 – Iterators | Dataplexa

Iterators

Every time you write a for loop in Python, something is happening behind the scenes that most beginners never think about. Python is not simply stepping through a list — it is asking an object for its next value, one at a time, using a protocol built into the language itself. That protocol is the iterator protocol, and understanding it changes the way you think about loops, data, and memory.

This lesson unpacks exactly how iteration works, shows you the built-in tools that leverage it, and teaches you to build your own iterable objects from scratch.

Iterables vs Iterators — The Key Distinction

These two terms are related but not the same, and confusing them is one of the most common sources of bugs for intermediate Python developers.

  • An iterable is any object you can loop over — lists, tuples, strings, dictionaries, sets, files, and ranges are all iterables. An iterable knows how to produce an iterator when asked.
  • An iterator is an object that does the actual work of stepping through values one at a time. It has an internal state that tracks where it is, and it produces the next value on demand.
  • Every iterator is also an iterable — but not every iterable is an iterator.
# The difference between an iterable and an iterator

nums = [10, 20, 30]   # this is an ITERABLE — it can produce an iterator

# iter() asks the iterable for its iterator
it = iter(nums)       # now 'it' is the ITERATOR

# next() asks the iterator for the next value
print(next(it))   # 10
print(next(it))   # 20
print(next(it))   # 30

# One more call raises StopIteration — nothing left
# print(next(it))  # would raise StopIteration
10
20
30
  • iter(obj) calls obj.__iter__() and returns the iterator
  • next(it) calls it.__next__() and returns the next value
  • When values are exhausted, next() raises StopIteration — this is the signal Python uses to end a for loop
  • A for loop is literally doing this behind the scenes on every iteration

How a for Loop Really Works

Now that you know about iter() and next(), you can see exactly what Python does when it executes a for loop. This mental model is essential for understanding why certain things behave the way they do.

# What Python does internally for every for loop

items = ["a", "b", "c"]

# What you write:
for item in items:
    print(item)

print("---")

# What Python actually does:
_it = iter(items)          # step 1 — get the iterator
while True:
    try:
        item = next(_it)   # step 2 — get next value
        print(item)        # step 3 — run the loop body
    except StopIteration:
        break              # step 4 — stop when exhausted
a
b
c
---
a
b
c
  • Both versions produce identical output — the for loop is syntactic sugar for this exact pattern
  • StopIteration is not an error — it is the normal, expected signal that iteration is complete
  • This model explains why you cannot loop over a plain integer — it has no __iter__ method

The Iterator Protocol — __iter__ and __next__

Any object can become an iterator by implementing two special methods. This is called the iterator protocol.

  • __iter__(self) — returns the iterator object itself (usually just return self)
  • __next__(self) — returns the next value, or raises StopIteration when done

Why it exists: by standardizing how objects expose their values one at a time, Python lets any object — not just lists — work seamlessly in for loops, list(), sum(), zip(), and every other place that consumes sequences.

Real-world use: a database cursor object implements the iterator protocol so you can loop over query results row by row without loading the entire result set into memory at once.

# Building a custom iterator from scratch

class Countdown:
    """Counts down from a given start number to 1."""

    def __init__(self, start):
        self.current = start   # track where we are

    def __iter__(self):
        return self            # the object is its own iterator

    def __next__(self):
        if self.current <= 0:
            raise StopIteration        # signal that we are done
        val = self.current
        self.current -= 1              # advance internal state
        return val

# Use it in a for loop — works like any built-in iterable
for n in Countdown(5):
    print(n, end=" ")
print()

# Also works with list(), sum(), etc.
print(list(Countdown(4)))
print(sum(Countdown(4)))
5 4 3 2 1
[4, 3, 2, 1]
10
  • Implementing __iter__ and __next__ is all it takes to make any class iterable
  • The iterator holds its own state — multiple independent iterators on the same data do not interfere
  • Once exhausted, this iterator cannot be reused — create a new instance to iterate again

Separating the Iterable from the Iterator

A common and cleaner design is to have one class represent the data (the iterable) and a separate class handle the traversal (the iterator). This lets you create multiple independent iterators over the same data at the same time.

# Separate iterable and iterator classes

class NumberRange:
    """The iterable — holds the data."""
    def __init__(self, start, end):
        self.start = start
        self.end   = end

    def __iter__(self):
        return NumberRangeIterator(self)   # return a fresh iterator

class NumberRangeIterator:
    """The iterator — handles traversal."""
    def __init__(self, source):
        self.current = source.start
        self.end     = source.end

    def __iter__(self):
        return self

    def __next__(self):
        if self.current > self.end:
            raise StopIteration
        val = self.current
        self.current += 1
        return val

r = NumberRange(1, 4)

# Two independent iterators over the same range
it1 = iter(r)
it2 = iter(r)

print(next(it1))   # 1  — it1 is at position 1
print(next(it1))   # 2  — it1 advances
print(next(it2))   # 1  — it2 is independent, still at 1

# A for loop creates its own fresh iterator automatically
for n in r:
    print(n, end=" ")
1
2
1
1 2 3 4
  • When __iter__ returns self, the object is both iterable and iterator — but it can only be traversed once
  • When __iter__ returns a fresh iterator object, you can create multiple independent traversals
  • Python's built-in types like list follow this pattern — iter(mylist) always returns a fresh iterator

Built-in Functions That Use Iterators

Once you understand the iterator protocol, you realize that most of Python's built-in functions work with any iterator — not just lists. This is what makes Python so composable.

# Built-in functions that consume any iterator

class Evens:
    """Yields even numbers from 2 up to a limit."""
    def __init__(self, limit):
        self.n     = 2
        self.limit = limit
    def __iter__(self): return self
    def __next__(self):
        if self.n > self.limit:
            raise StopIteration
        val   = self.n
        self.n += 2
        return val

evens = Evens(10)

print(list(evens))           # convert to list — [2, 4, 6, 8, 10]

evens = Evens(10)
print(sum(evens))            # sum all values — 30

evens = Evens(10)
print(max(evens))            # largest value — 10

evens = Evens(6)
print(list(enumerate(evens)))   # pair each with an index
[2, 4, 6, 8, 10]
30
10
[(0, 2), (1, 4), (2, 6)]
  • list(), tuple(), set(), sum(), min(), max(), sorted(), enumerate(), zip() — all accept any iterator
  • Each call consumes the iterator — create a new instance if you need to iterate again
  • This is why generators (next lesson) are so powerful — they are iterators too

iter() with a Sentinel Value

There is a two-argument form of iter() that is less well known but extremely useful. iter(callable, sentinel) calls the callable repeatedly until it returns the sentinel value, then stops — no class or __next__ needed.

Real-world use: reading a file in fixed-size chunks until an empty bytes object signals the end of the file.

# iter() with a sentinel — call a function until a value is returned

import random

random.seed(42)   # fixed seed so output is predictable

# Roll a die repeatedly until we get a 6
roller = iter(lambda: random.randint(1, 6), 6)

rolls = list(roller)   # collect all rolls before the first 6
print("Rolls before 6:", rolls)
Rolls before 6: [1, 5, 3, 2, 4]
  • iter(callable, sentinel) creates an iterator that calls callable() each time next() is invoked
  • When the callable returns a value equal to the sentinel, StopIteration is raised automatically
  • The sentinel value itself is never included in the results

Summary Table

Concept What It Is Key Method / Tool
Iterable Object that can produce an iterator __iter__()
Iterator Object that yields values one at a time __iter__() + __next__()
iter() Gets the iterator from an iterable Built-in function
next() Retrieves the next value from an iterator Built-in function
StopIteration Signal that iteration is complete Raised by __next__
Iterator protocol Contract any iterable class must fulfill __iter__ + __next__
Sentinel form Calls a function until a value is hit iter(callable, sentinel)

Practice Questions

Practice 1. What built-in function do you call to get an iterator from an iterable?



Practice 2. What exception does __next__ raise to signal that there are no more values?



Practice 3. What are the two special methods an object must implement to satisfy the iterator protocol?



Practice 4. What does the two-argument form iter(callable, sentinel) do?



Practice 5. Is every iterator also an iterable?



Quiz

Quiz 1. What does Python do internally at the start of every for loop?






Quiz 2. What is the difference between an iterable and an iterator?






Quiz 3. If you call next() on an exhausted iterator, what happens?






Quiz 4. Why does separating the iterable class from the iterator class allow multiple independent traversals?






Quiz 5. Which of the following built-in functions does NOT work with a custom iterator?






Next up — Generators shows you a far simpler way to build iterators using the yield keyword, and explains why they are the preferred tool for working with large or infinite sequences.