Course topics

By WebNest Studio Engineering Team

Python Tutorial

Python Iterators and Generators

Iteration — processing a sequence of values one at a time — is one of the most common patterns in programming, and Python formalizes it through the iterator protocol. Understanding this protocol explains what actually happens under the hood every time you write a "for" loop, and it opens the door to generators, which let you produce sequences of values lazily instead of building them all in memory up front.

This distinction matters enormously for performance: a generator that produces a billion values one at a time uses a small, constant amount of memory, while a list holding a billion values does not.

Why it matters

Every for loop in Python uses an iterator, and generators are the easy way to write one. A generator produces values one at a time and keeps nothing in memory, so it can process a file of any size or an endless stream. They are used throughout the standard library and are a favourite interview topic.

The Iterator Protocol: __iter__ and __next__

An "iterable" is any object you can loop over — it implements __iter__, which returns an "iterator." An iterator implements __next__, which returns the next value each time it's called, and raises the built-in StopIteration exception once there are no more values left. A "for" loop is essentially syntactic sugar: it calls iter(obj) once to get an iterator, then repeatedly calls next() on it until StopIteration is raised, at which point the loop ends cleanly.

Generator Functions and yield

Writing a full class with __iter__ and __next__ just to produce a sequence of values is verbose. A generator function is a much simpler shortcut: any function containing the yield keyword automatically becomes a generator function, and calling it returns a generator object (which already satisfies the iterator protocol) rather than running the function body immediately. Each call to next() resumes the function from exactly where it last paused at a yield, runs until the next yield (or the function ends), and returns that yielded value.

Memory Efficiency: Generators vs Lists

A list comprehension like [x * x for x in range(10_000_000)] builds the entire list of ten million values in memory before you can use any of them. A generator expression, written with parentheses instead of brackets — (x * x for x in range(10_000_000)) — or an equivalent generator function, computes each value only when it's requested and never holds the whole sequence in memory at once. This "lazy evaluation" is exactly why functions like range() in Python 3, and many standard library iteration tools, are designed around the iterator protocol rather than returning full lists.

Examples

A custom iterator implemented with __iter__ and __next__

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

for number in CountDown(3):
    print(number)
Output
3
2
1

A generator function using yield, compared to a list-building equivalent

Python
def countdown_gen(start):
    current = start
    while current > 0:
        yield current
        current -= 1

gen = countdown_gen(3)
print(next(gen))
print(next(gen))
print(list(gen))       # exhausts the remaining values

import sys
list_version = [x for x in range(100000)]
gen_version = (x for x in range(100000))
print(sys.getsizeof(list_version) > sys.getsizeof(gen_version))
Output
3
2
[1]
True

Common Mistakes

  • Trying to reuse an exhausted generator — once a generator has raised StopIteration, it stays exhausted and must be recreated, not reset.
  • Calling len() on a generator, which fails because generators do not know their total length in advance without being fully consumed.
  • Building a huge list with a list comprehension when a generator expression would do, wasting memory on data that is only ever processed one item at a time.
  • Forgetting that a generator function's body does not execute at all until the first call to next() (or the first iteration of a for loop) — calling the function alone just creates the generator object.

Exercise

Write two generator functions. evens(limit) yields the even numbers below the limit, and countdown(n) yields n, n-1, ... down to 1. The program prints the evens below 10, then takes one value from a countdown with next, then prints what is left.

Expected output: [0, 2, 4, 6, 8], 3, [2, 1] (one per line)

Python · starter code
def evens(limit):
    # TODO: yield each even number below limit
    pass


def countdown(n):
    # TODO: yield n, n - 1, ... 1
    pass


print(list(evens(10)))

timer = countdown(3)
print(next(timer))
print(list(timer))
Show hints
  • A function becomes a generator as soon as its body contains yield.
  • A generator remembers where it stopped, so list(timer) gives only the values not yet taken.
Show solution
Python · solution
def evens(limit):
    for number in range(0, limit, 2):
        yield number


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


print(list(evens(10)))

timer = countdown(3)
print(next(timer))
print(list(timer))

Quick quiz

1. Which exception signals that an iterator has no more values?

  • A. IndexError
  • B. EOFError
  • C. StopIteration
  • D. ValueError
Show answer

C. A for loop catches it automatically and ends.

2. A generator has been looped over completely. What does a second loop over the same generator produce?

  • A. The same values again
  • B. Nothing; a generator can be consumed only once
  • C. An error
  • D. The values in reverse
Show answer

B. Call the generator function again to get a fresh generator.

3. What is the main advantage of a generator over building a list?

  • A. It is always faster
  • B. It can be sorted in place
  • C. It can be indexed
  • D. Values are produced one at a time, so memory use stays small
Show answer

D. A list of a million items occupies memory for all of them; a generator holds one at a time.

Interview questions

What is the difference between an iterable and an iterator?

Show answer

An iterable is any object that can be looped over, such as a list, string or dictionary; it has an __iter__ method that returns an iterator. An iterator is the object that does the stepping: it has a __next__ method that returns the next value and raises StopIteration at the end. A list can be iterated many times, each time with a new iterator; an iterator is used up as it goes.

What is the difference between yield and return?

Show answer

return ends the function and hands back one value. yield hands back a value and pauses the function, keeping its local variables; the next request resumes it from the line after the yield. A function containing yield returns a generator when called and runs none of its body until the first value is requested.

Key Points to Remember

  • An iterable implements __iter__ (returns an iterator); an iterator implements __next__ (returns values, then raises StopIteration).
  • A for loop is sugar for calling iter() once and next() repeatedly until StopIteration.
  • Any function containing yield is a generator function; calling it returns a generator without running the body immediately.
  • Generators compute values lazily and use constant memory, unlike lists which hold every value at once.

Practice in the playground

Write and run your own code for this topic, or start from the exercise or an example. Runs Python in your browser. Your code for each topic is kept in this browser.

Run the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.

WebNest Studio

Written by

WebNest Studio Engineering Team

Practical lessons from developers working with Java, Python, React, databases and production systems.

Led by Vansh Duggal, Founder, WebNest Studio · Full-stack & software engineering