Skip to content

Iterators, Generators & Decorators

Three intermediate-level features that show up everywhere in real Python code.

Iterators — the protocol behind for

When you write for x in something, Python calls iter() on it to get an iterator, then keeps calling next() until StopIteration is raised.

nums = [10, 20, 30]
it = iter(nums)         # get an iterator from a list

print(next(it))         # 10
print(next(it))         # 20
print(next(it))         # 30

try:
    print(next(it))     # raises StopIteration — list exhausted
except StopIteration:
    print("Out of items.")

So for is just sugar for this:

nums = [10, 20, 30]
it = iter(nums)
while True:
    try:
        x = next(it)
    except StopIteration:
        break
    print(x)

Building a custom iterator

A class becomes iterable by defining __iter__ and __next__:

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

    def __iter__(self):
        return self                      # the object itself is the iterator

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        self.current -= 1
        return self.current + 1          # before decrement

for n in Countdown(5):
    print(n, end=" ")
print()

Generators — easier iterators with yield

Writing iterators by hand is tedious. Generator functions use yield to give one value at a time:

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

for x in countdown(5):
    print(x, end=" ")
print()

Every time yield runs, the function pauses and returns the value. Next iteration, it resumes from there.

Why generators are amazing

They're lazy — values are computed on demand. Great for large or infinite sequences.

def all_squares():
    n = 1
    while True:
        yield n * n
        n += 1

g = all_squares()
for _ in range(10):
    print(next(g), end=" ")
print()

An "infinite" sequence, but using almost no memory.

Compare to a list — the list version would never finish:

# squares = [n*n for n in range(infinity)]   # would hang forever

Generator expressions — one-liners

Like list comprehensions but with () instead of []:

# List — computes ALL values up front
squares_list = [n*n for n in range(1_000_000)]

# Generator — lazy, computes on demand
squares_gen = (n*n for n in range(1_000_000))

# Same behavior with for, but generator uses far less memory
print(sum(squares_gen))

Use a generator when you only iterate once and don't need all values at the same time.

Useful built-in generators

  • range(n) — already a generator-like object
  • enumerate(items), zip(a, b) — return iterators
  • map(fn, items), filter(fn, items) — return iterators
# Show that map/filter are lazy iterators, not lists
m = map(lambda x: x*2, [1, 2, 3])
print(type(m))            # <class 'map'>
print(list(m))            # convert to list to see values

Mini-project — Fibonacci generator

def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

fib = fibonacci()
first_10 = [next(fib) for _ in range(10)]
print(first_10)

Decorators

A decorator is a function that wraps another function to add behavior — without changing the original.

A simple decorator

def shout(fn):
    def wrapper(*args, **kwargs):
        result = fn(*args, **kwargs)
        return result.upper() + "!!!"
    return wrapper

@shout
def greet(name):
    return f"hello, {name}"

print(greet("alice"))         # "HELLO, ALICE!!!"
print(greet("bob"))           # "HELLO, BOB!!!"

@shout is the same as greet = shout(greet). The decorator replaces greet with the wrapper function.

A timing decorator (real-world)

import time

def timed(fn):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = fn(*args, **kwargs)
        elapsed = time.time() - start
        print(f"{fn.__name__} took {elapsed*1000:.2f} ms")
        return result
    return wrapper

@timed
def slow_add(a, b):
    time.sleep(0.05)
    return a + b

print(slow_add(3, 4))
print(slow_add(10, 20))

A logging decorator

def log_calls(fn):
    def wrapper(*args, **kwargs):
        print(f"→ Calling {fn.__name__}({args}, {kwargs})")
        result = fn(*args, **kwargs)
        print(f"← {fn.__name__} returned {result!r}")
        return result
    return wrapper

@log_calls
def add(a, b):
    return a + b

@log_calls
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

add(3, 4)
greet("Alice", greeting="Hi")

Stacking decorators

You can apply several — they run from the closest to the function outward:

def loud(fn):
    def wrapper(*a, **kw):
        return f"!!! {fn(*a, **kw)} !!!"
    return wrapper

def angry(fn):
    def wrapper(*a, **kw):
        return fn(*a, **kw).upper()
    return wrapper

@loud      # applied SECOND
@angry     # applied FIRST
def greet(name):
    return f"hello, {name}"

print(greet("alice"))    # "!!! HELLO, ALICE !!!"

Decorators that take arguments

Need an extra layer:

def repeat(times):
    def decorator(fn):
        def wrapper(*args, **kwargs):
            for _ in range(times):
                fn(*args, **kwargs)
        return wrapper
    return decorator

@repeat(3)
def hello(name):
    print(f"Hello, {name}")

hello("Alice")

Real-world decorators you'll use

  • @property, @staticmethod, @classmethod (see OOP)
  • @functools.cache / @functools.lru_cache — memoize function results
  • @dataclasses.dataclass — auto-generate __init__, __repr__
  • @app.route("/") in Flask, @app.get("/") in FastAPI

@cache example — speeds up recursive Fibonacci dramatically:

from functools import lru_cache
import time

def fib_slow(n):
    if n < 2: return n
    return fib_slow(n-1) + fib_slow(n-2)

@lru_cache(maxsize=None)
def fib_fast(n):
    if n < 2: return n
    return fib_fast(n-1) + fib_fast(n-2)

start = time.time()
print("slow fib(30) =", fib_slow(30))
print(f"  took {time.time()-start:.3f}s")

start = time.time()
print("fast fib(30) =", fib_fast(30))
print(f"  took {time.time()-start:.3f}s")

The cache version is hundreds of times faster — because it doesn't recompute the same fib(k) repeatedly.

Preserve function metadata — functools.wraps

Without wraps, the decorated function loses its name and docstring:

from functools import wraps

def timed(fn):
    @wraps(fn)          # preserves fn.__name__ and fn.__doc__
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

@timed
def my_function():
    """The original docstring."""
    pass

print(my_function.__name__)    # 'my_function' (not 'wrapper')
print(my_function.__doc__)     # 'The original docstring.'

Always use @functools.wraps in your decorators. Free, no downside.

Wrap-up

Concept Use it for
Iterator Anything that can be looped (built-in or custom).
Generator (yield) Lazy sequences — large or infinite data.
Generator expression (... for ...) One-liner lazy sequences.
Decorator (@) Add cross-cutting behavior — logging, timing, caching, auth.
@functools.wraps Always inside your decorators.
@functools.cache Memoize pure functions.

Practice

What does this print?

Expected: 0 1 1 2 3

def fib():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

g = fib()
print(next(g), next(g), next(g), next(g), next(g))

Make count_to(3) produce 1, 2, 3 (currently produces nothing)

Expected: 1 2 3

def count_to(n):
    for i in range(1, n + 1):
        print(i, end=" ")          # bug: prints, doesn't yield
for x in count_to(3):
    pass

Quiz — Quick check

What you remember

Q1. What's the difference between return and yield?

  • No difference
  • return ends the function once; yield pauses it and resumes on the next call
  • yield is only valid in classes
  • return is faster

Why: A function with yield is a generator. Each next() call resumes the function right after the last yield. return terminates the function entirely.

Q2. Why use a generator expression (x*x for x in range(N)) instead of a list comprehension [x*x for x in range(N)]?

  • Generators are syntactically shorter
  • Generators compute values lazily — use far less memory for large or infinite sequences
  • Lists don't support for loops
  • Generators are easier to read

Why: A list comprehension allocates every value up front. A generator produces one at a time. For 10 million squares, the list uses ~80MB; the generator uses ~200 bytes.

Q3. What does @functools.wraps(fn) do inside a decorator?

  • Caches the function's results
  • Makes the function asynchronous
  • Copies the wrapped function's name and docstring onto the wrapper
  • Validates arguments

Why: Without @wraps, the decorated function would report its name as "wrapper" (whatever you named the inner function). @wraps(fn) restores __name__, __doc__, and other metadata so debugging and help() still work properly.

Common doubts

When should I use a generator vs a list?

Use a generator when (a) the sequence is huge or infinite, (b) you only iterate once, or © you might stop early. Use a list when you need to index, slice, iterate multiple times, or know the length up front.

Can I return something in a generator?

Yes — return value inside a generator becomes the value attached to the StopIteration exception when the generator finishes. Rarely needed in practice; you mostly use yield and let the function fall off the end.

How does @decorator actually work mechanically?

@decorator above def foo(): is just syntactic sugar for foo = decorator(foo). The decorator receives the original function, wraps it (usually in a new function), and returns the replacement. That's why every decorator returns a function — that returned function replaces the original name.

← Back to Python home