Index Ai Academy
Phase 7 · Software engineering · Python in depth

Iterators and generators

The iteration protocol, yield, lazy pipelines and itertools, in small memory.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

A for loop over a list is fine for a thousand items. A log file with ten million lines, a stream of API pages, or batches of training data for a model won't fit in memory as one list. Generators produce values one at a time, only when asked, so a program can process data far bigger than memory with a few lines of ordinary-looking code. PyTorch's data loaders, file reading and many APIs work this way.

What you'll be able to do

  • Explain the iteration protocol: iter(), next() and StopIteration
  • Write generator functions with yield and yield from, and generator expressions
  • Chain generators into pipelines that process big inputs in small, constant memory
  • Reach for itertools for common patterns: islice, chain, groupby, accumulate

What it covers

  1. What a for loop really does
  2. Generators: functions that pause
  3. Generator expressions, and what memory costs
  4. Pipelines
  5. Batches
  6. itertools: the standard pipeline parts
  7. Where laziness pays, and where it doesn't
  8. Worked example: daily totals from a big log, in small memory

What a for loop really does

A for loop works on anything iterable. It calls iter() to get an iterator, then calls next() on it until the iterator raises StopIteration:

skus = ["TE-101", "PA-108", "FO-120"]
it = iter(skus)
print(next(it))
print(next(it))
print(next(it))
try:
    next(it)
except StopIteration:
    print("done: that's how a for loop knows to stop")

with open("stock.txt", "w") as f:
    f.write("TE-101 4\nPA-108 0\nFO-120 12\n")
with open("stock.txt") as f:
    print(next(f).strip())
    for line in f:
        print("rest:", line.strip())
It runs right here in your browser, with nothing to install.

A list is iterable but isn't an iterator itself: every iter(skus) call makes a fresh iterator that starts at the beginning, which is why you can loop over a list as often as you like. An iterator remembers where it is, and it's used up once it's done. A file object is its own iterator: after next(f) reads the first line, the for loop carries on from the second. That's also why a file can be read line by line without loading the whole thing.

Generators: functions that pause

A function containing yield is a generator function. Calling it doesn't run the body; it returns a generator. Each next() runs the body until the next yield, hands out that value, and pauses right there, with all its local variables kept, until the next request:

def countdown(n):
    print("starting")
    while n > 0:
        yield n
        n -= 1
    print("finished")

gen = countdown(3)
print("created, nothing ran yet")
print(next(gen))
print(list(gen))

def pages(total, size):
    for start in range(0, total, size):
        yield range(start, min(start + size, total))

for page in pages(7, 3):
    print(list(page))

"starting" only prints when the first value is asked for. list(gen) asks for everything that's left, so it runs to the end and prints "finished". The pages generator is the shape of every paginated API client: it yields one page at a time, so the caller never has to deal with the paging.

Try it now

Step through: the generator's body only runs when next() or list() asks for a value, and each yield pauses it with n still remembered.

Quick check
What does this print?
def evens(limit):
    for n in range(limit):
        if n % 2 == 0:
            yield n
g = evens(7)
print(next(g), next(g), sum(g))
  1. 0 2 4
  2. 0 2 12
  3. 0 1 10
  4. 0 2 10
Show the answer

0 2 10

The evens below 7 are 0, 2, 4, 6. Two next() calls take 0 and 2; sum() consumes the rest: 4 + 6 = 10.

Generator expressions, and what memory costs

A generator expression looks like a list comprehension with round brackets. It produces items lazily instead of building a list. tracemalloc measures how much memory Python allocates, so the difference can be seen:

import tracemalloc

tracemalloc.start()
squares = [n * n for n in range(1_000_000)]
total = sum(squares)
_, peak_list = tracemalloc.get_traced_memory()
del squares
tracemalloc.reset_peak()

total2 = sum(n * n for n in range(1_000_000))
_, peak_gen = tracemalloc.get_traced_memory()
tracemalloc.stop()

print(total == total2)
print(f"list: {peak_list / 1e6:.1f} MB, generator: {peak_gen / 1e6:.3f} MB")

The list holds a million numbers at once; the generator holds one at a time. When a function like sum, max, any or "".join is the only argument's consumer, you can even drop the extra brackets: sum(n * n for n in range(10)).

The trade-off: a generator can be read only once, and has no len() or [i]. If you need the items several times, or need to index them, make a list.

Pipelines

Generators chain together. Each stage takes an iterable and yields transformed items, so data flows through the whole pipeline one item at a time:

def read_lines(lines):
    for line in lines:
        yield line.rstrip("\n")

def parse(lines):
    for line in lines:
        if line and not line.startswith("#"):
            date, sku, qty = line.split(",")
            yield {"date": date, "sku": sku, "qty": int(qty)}

def only(prefix, rows):
    yield from (r for r in rows if r["sku"].startswith(prefix))

raw = ["# date,sku,qty\n", "2025-06-01,TE-101,1\n", "2025-06-01,PA-108,2\n", "\n", "2025-06-02,TE-104,1\n"]
tents = only("TE", parse(read_lines(raw)))
for row in tents:
    print(row)

yield from hands out every item of another iterable, one by one. Swap raw for an open file and the same pipeline handles a 20 GB log in a few kilobytes of memory. Each stage stays small and testable on a short list, which is how data-loading code for machine learning is usually built.

Drill

Batches

Write a generator batches(items, size) that yields lists of up to size items, the way training code feeds a model in mini-batches. It must work on any iterable, including another generator, without making a full list first.

def batches(items, size):
    pass


print(list(batches(range(7), 3)))
In the lesson, your code is checked as you write it.

itertools: the standard pipeline parts

The itertools module has the stages you'd otherwise write by hand:

from itertools import accumulate, chain, groupby, islice, count

print(list(islice(count(1000), 3)))
print(list(chain(["TE-101"], ("PA-108", "FO-120"))))
print(list(accumulate([120, 45, 300])))

rows = [("2025-06-01", 3), ("2025-06-01", 1), ("2025-06-02", 5), ("2025-06-02", 2), ("2025-06-03", 4)]
for day, group in groupby(rows, key=lambda r: r[0]):
    print(day, sum(qty for _, qty in group))
  • islice takes a slice of any iterator, even an endless one like count().
  • chain joins iterables end to end.
  • accumulate gives running totals.
  • groupby groups neighboring items with the same key. Unlike pandas' groupby, it only works on input that's already sorted by that key; an unsorted day would start a new group. Logs written in time order are sorted already, which is why groupby suits them.

Where laziness pays, and where it doesn't

Lazy iteration is everywhere once you look:

  • Files are read a line at a time with for line in f. Never call f.readlines() on a file that might be big.
  • APIs with pages: a client generator yields items across pages, so callers write one simple loop and only the pages they actually reach get fetched.
  • Databases: cursors hand back rows in batches, and ORMs can stream results instead of loading a whole table.
  • Machine learning: training code loops over a data loader that yields one mini-batch at a time, because a dataset of images or text rarely fits in memory.
  • Built-ins: range, zip, map, enumerate and dict.items() are all lazy already.

Laziness has costs too. A generator can be used only once, so if two parts of a program need the same data, give them a list or create the generator twice. Errors appear late, when the item that triggers them is reached, not when the generator is created. And a pipeline is harder to inspect in the middle: when debugging, list(islice(stage, 5)) shows the first five items any stage produces. Use generators when the data is big, endless or expensive to fetch, and plain lists when it's small and used more than once. Most real programs mix both: a lazy pipeline that reads and filters, ending in a small list of results.

Worked example

Worked example: daily totals from a big log, in small memory

Build a 200,000-line order log as a generator, total it per day through a pipeline, and measure peak memory.

import random
import tracemalloc
from itertools import groupby

def order_log(days, per_day, seed=1):
    rng = random.Random(seed)
    for d in range(days):
        date = f"2025-01-{d % 28 + 1:02d}" if d < 28 else f"2025-02-{d - 27:02d}"
        for _ in range(per_day):
            yield f"{date},{rng.choice(['TE-101', 'PA-108', 'FO-120'])},{rng.randint(1, 3)},{rng.choice([20, 55, 200])}"

def parse(lines):
    for line in lines:
        date, sku, qty, price = line.split(",")
        yield date, int(qty) * int(price)

def daily_totals(rows):
    for date, group in groupby(rows, key=lambda r: r[0]):
        yield date, sum(amount for _, amount in group)

tracemalloc.start()
totals = list(daily_totals(parse(order_log(days=40, per_day=5000))))
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(len(totals), "days; first:", totals[0], "last:", totals[-1])
print(f"peak memory: {peak / 1e6:.2f} MB for 200,000 lines")
In the lesson you can change this and run it.

order_log makes lines on demand, the way reading a file would. parse and daily_totals each handle one item at a time, and groupby works because the log is in date order. Only the 40 daily totals are ever kept: the peak memory stays far below what 200,000 lines held in a list would need. To run the same pipeline on a real file, replace order_log(...) with open("orders.csv").

Practice what you just read

  • 6 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

When does the body of a generator function start running?

  1. When the first value is asked for with next() or a loop
  2. When the function is called
  3. When the module is imported
  4. When the generator is garbage collected

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson