You’ve been using iterables since the start of the course — every for loop walks over one. This lesson explains the small but important distinction between an iterable and an iterator.

  • An iterable is any object you can iterate over.
  • An iterator is an object that does the iterating.

A list is an iterable. When you write for x in my_list, Python asks the list for an iterator behind the scenes, then uses that iterator to walk through.

Looking at it directly

The built-in iter() turns an iterable into an iterator:

nums: list[int] = [10, 20, 30]
it = iter(nums)        # an iterator over nums

print(it)              # <list_iterator object at 0x...>

Now you can ask the iterator for one item at a time using next():

print(next(it))    # 10
print(next(it))    # 20
print(next(it))    # 30
print(next(it))    # StopIteration error — no more items

A for loop is essentially:

it = iter(nums)
while True:
    try:
        item = next(it)
    except StopIteration:
        break
    print(item)

You’d never write it this way by hand — for x in nums: does the same thing in two characters of syntax. But the underlying mechanics are useful to know.

Iterables are reusable; iterators are not

A list can be iterated many times:

nums: list[int] = [1, 2, 3]

for n in nums:
    print(n)    # 1, 2, 3

for n in nums:
    print(n)    # 1, 2, 3  — works again

An iterator can be iterated once. After it’s exhausted, it’s done:

it = iter(nums)

for n in it:
    print(n)    # 1, 2, 3

for n in it:
    print(n)    # nothing! it is exhausted

This is the key difference. An iterable is a recipe; an iterator is a fresh chef who finishes their work and goes home.

Why this matters

Many of Python’s built-ins (map, filter, zip, enumerate) return iterators, not lists. (range is also lazy, but it’s an iterable — you can loop over it more than once.)

m = map(lambda n: n * 2, [1, 2, 3])
print(list(m))    # [2, 4, 6]
print(list(m))    # [] — m is exhausted

If you find yourself looping over a map/filter result twice and the second time is empty, this is why. Wrap it in list() first if you need to iterate more than once:

m = list(map(lambda n: n * 2, [1, 2, 3]))

Lazy evaluation — the big benefit

Iterators don’t produce all their values at once. They produce them on demand. This means you can work with huge — or even infinite — sequences without using up memory.

big_range = range(1_000_000_000)
print(big_range)         # range(0, 1000000000)
print(big_range[0])      # 0 — instant

# this would crash a normal computer — a billion ints in a list
# big_list = list(range(1_000_000_000))

range(1_000_000_000) doesn’t allocate a billion numbers. It’s an iterable that produces them one at a time when you walk it. Same goes for map, filter, zip, and most of the standard library.

A common iterator pattern — peeking

If you need the first item of an iterator without consuming the whole thing:

def first(iterable):
    return next(iter(iterable))


print(first([10, 20, 30]))   # 10
print(first(range(5, 100)))  # 5

next() pulls one item. After that, you could keep going with the same iterator if you wanted.

itertools — the iterator toolbox

The standard library has a whole module of iterator utilities:

import itertools

# infinite counter — must break out yourself
for i in itertools.count(0):
    print(i)
    if i >= 3:
        break

# chain — join iterables end to end
list(itertools.chain([1, 2], [3, 4]))      # [1, 2, 3, 4]

# islice — slicing for iterators
list(itertools.islice(range(100), 5))      # [0, 1, 2, 3, 4]

We won’t tour itertools here — but know it exists for when you need it.

What’s next

You understand the protocol behind for. Next, the iter/next interface in more detail — and how to build your own iterators.

Toggle theme (T)