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.