Skip to content

Expert · Advanced Python · topic 40 of 50 · 30 min

Iterators and generators

Explanation

An iterable is anything you can loop over; an iterator is the object that hands out its items one at a time and remembers where it is. A generator is a function that uses yield to produce values one by one, only when they are asked for. This "lazy" style saves memory, so you can work with millions of items or even endless streams.

Two words that sound alike: iterable and iterator

An iterable is anything you can use in a for loop: a list, a string, a tuple, a dict, a set, a range, an open file. An iterator is a different, smaller object. Its only job is to give you the next item each time you ask, and to remember how far it has reached.

Think of it like this: Think of a music playlist and the "next" button on a phone. The playlist is the iterable: it is the full list of songs. The player that is playing it is the iterator: it knows which song is current and moves forward when you press next. Two phones can play the same playlist, each at its own song.

iter() and next()

The built-in function iter() asks an iterable for a fresh iterator. The built-in function next() asks an iterator for its next item. When nothing is left, next() raises a special exception called StopIteration. That exception is not a bug; it is the signal for "the end".

python
marks = [72, 88, 95]
it = iter(marks)      # get an iterator from the list
print(next(it))       # 72
print(next(it))       # 88
print(next(it))       # 95
try:
    next(it)          # nothing left
except StopIteration:
    print("No more items")
next() moves forward one step each time. After the last item it raises StopIteration.

A for loop is a short way of writing the steps above. Python calls iter() on your iterable once, then calls next() again and again, and stops quietly when it sees StopIteration. Knowing this explains a lot of Python behaviour that looks like magic.

python
cities = ["Pune", "Jaipur", "Kochi"]
it = iter(cities)
while True:
    try:
        city = next(it)
    except StopIteration:
        break
    print(city)
This while loop does exactly what "for city in cities: print(city)" does.

A protocol is an agreed set of method names. Any object becomes an iterator if it has two dunder methods: __iter__, which returns the iterator itself, and __next__, which returns the next item or raises StopIteration. You rarely write these by hand, because generators (below) do it for you.

python
class Countdown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        value = self.current
        self.current -= 1
        return value

print(list(Countdown(3)))   # [3, 2, 1]
A hand-written iterator class: it works in for loops, list(), sum() and everywhere else.

Generator functions and yield

A generator function is a normal def that contains the word yield. Calling it does not run the body. Instead it returns a generator object, which is an iterator. Each next() runs the body until the next yield, hands out that value, and pauses. Local variables are kept safe until the next call.

python
def countdown(n):
    while n > 0:
        yield n
        n -= 1

gen = countdown(3)
print(gen)          # <generator object countdown at ...>
print(next(gen))    # 3
print(list(gen))    # [2, 1]  (the rest of the values)
Same result as the Countdown class, in four lines.

Lazy evaluation means a value is worked out only when someone asks for it. A list stores all its items in memory at once. A generator stores only its current position, so it uses the same small memory for ten items or ten million items.

python
import sys

squares_list = [x * x for x in range(1_000_000)]   # list comprehension
squares_gen = (x * x for x in range(1_000_000))    # generator expression

print(sys.getsizeof(squares_list))   # millions of bytes
print(sys.getsizeof(squares_gen))    # around 200 bytes
print(sum(x * x for x in range(1_000_000)))
Round brackets instead of square brackets make a generator expression. You can pass it straight into sum(), max() or any().
Careful: A generator can be used only once. After you loop over it to the end it is empty, and a second loop gives nothing, with no error. If you need the values twice, either call the generator function again or store the values in a list with list(gen).

yield from some_iterable yields every item of another iterable, one by one. It is a short form of "for item in some_iterable: yield item", and it is handy when one generator is built from smaller ones.

python
def all_orders(shop_a, shop_b):
    yield from shop_a
    yield from shop_b

print(list(all_orders(["dosa", "idli"], ["vada pav"])))
Shows ['dosa', 'idli', 'vada pav'].
ListGenerator
MemoryHolds every itemHolds only its current position
ReuseLoop as many times as you likeUsed once, then empty
Indexing like x[3] and len()YesNo
Best forSmall data you reuseBig or endless data, pipelines
Tip: Chain small generators into a pipeline: one reads lines, the next cleans them, the next filters them. Each line flows through all steps one at a time, so even a 5 GB log file needs very little memory.

Watch the lesson

Iterators and generators · 1/7

Iterators and generators

In this lesson you will learn how Python loops really work, and how generators let you handle huge amounts of data with very little memory.

Prefer a video by a teacher? Find free videos on “python iterators and generators yield explained” on YouTube ↗

In real life: Reading a huge railway log one line at a time

A ticket booking server writes millions of lines to a log every day. Loading the whole file into a list could use gigabytes of memory. A generator pipeline reads one line, keeps it only if it is an error, and moves on, so the program runs on a small server. Here a list stands in for the file, but the code is the same for an open file.

python
log = [
    "10:01 OK booking PNR1001",
    "10:02 ERROR payment timeout",
    "10:03 OK booking PNR1002",
    "10:04 ERROR seat lock failed",
]

def errors(lines):
    for line in lines:
        if "ERROR" in line:
            yield line

def messages(lines):
    for line in lines:
        yield line.split(" ", 2)[2]

for msg in messages(errors(log)):
    print(msg)
Two small generators chained together. Only one line is in memory at a time.

Practice (0/3 done)

Task 1 · Code · Python

Write a generator function countdown(n) that yields n, n-1, ... down to 1. list(countdown(3)) should be [3, 2, 1]. It must use yield (the checker tests that it returns a generator).

Task 2 · Code · Python

Write a generator chunks(items, size) that yields lists of at most size items. chunks([1, 2, 3, 4, 5], 2) should give [1, 2], then [3, 4], then [5]. This is how apps send big data in small batches.

Task 3 · Code · Python

Write take(n, iterable) that returns a LIST of the first n items of any iterable, using iter() and next(). It must also work on an endless iterator such as itertools.count(), so do not convert the whole iterable to a list. If the iterable has fewer than n items, return all of them.

Quick quiz

Question 1

1.What does next() do when an iterator has no items left?

Question 2

2.What happens when you call a function that contains yield?

Question 3

3.gen = (x for x in range(3)). What do list(gen) and then list(gen) again give?

Question 4

4.Why is sum(x * x for x in range(10_000_000)) lighter on memory than sum([x * x for x in range(10_000_000)])?

Question 5

5.Which two methods make a class an iterator?

Read more (free)

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

← Previous: Dataclasses and properties