devnotes.

Generators and yield

A generator function — any function containing yield — is the easiest way to build a custom iterator, without manually writing __iter__/__next__.

def count_up(limit):
    current = 1
    while current <= limit:
        yield current       # pauses here, returns current, resumes on the next next()
        current += 1

for n in count_up(5):     # works exactly like a normal iterable
    print(n)                 # 1, 2, 3, 4, 5

def infinite_counter():     # generators can represent infinite sequences safely
    n = 1
    while True:
        yield n
        n += 1

counter = infinite_counter()
print(next(counter))    # 1
print(next(counter))    # 2 — only computes values as they're requested

# Generator expression (from an earlier lesson) is a compact one-line generator:
squares = (n ** 2 for n in range(1000000))   # nothing computed yet, all lazy

yield pauses a function's execution, sends a value out to the caller, and — crucially — remembers exactly where it left off, resuming from that exact point the next time the generator is asked for a value (via next() or a for loop). This is fundamentally different from return, which exits the function permanently.

Because values are produced lazily, one at a time on demand, generators can represent infinite sequences (like infinite_counter above) without ever running out of memory — something a list-returning function fundamentally could not do. This lazy evaluation is also why generators are far more memory-efficient than building a full list upfront when processing large datasets: a generator processing a multi-gigabyte file line-by-line never holds more than one line in memory at a time.

A generator is a specific, easy-to-write instance of the general iterator protocol from the previous lesson — writing yield inside a function automatically implements __iter__ and __next__ for you, which is why generators are almost always preferred over writing a full class-based iterator by hand.

Generators and yield in Python | DevNotes