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.
Two related ideas
- 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.