Skip to content

Generators & yield Interview Questions & Answers

15 questions Updated 2026-06-18 Share:

Python interview questions on generators and yield, lazy evaluation, generator expressions vs list comprehensions, yield from, and infinite generators.

Read the in-depth guidePython Generators vs Iterators vs Comprehensions — Choosing the Right Tool(opens in new tab)
15 of 15

A generator is a function that produces a lazy sequence of values one at a time. Any function containing yield becomes a generator function: calling it doesn't run the body — it returns a generator object (an iterator). Each time you call next() (or iterate), the body runs until the next yield, hands back that value, and pauses, preserving all local state.

def counter():
    print("start")
    yield 1
    yield 2          # execution pauses here between next() calls
    yield 3

g = counter()        # nothing printed yet — body hasn't run
next(g)              # prints "start", returns 1
next(g)              # returns 2 (resumes after first yield)

When the function returns (or falls off the end), a StopIteration is raised to signal exhaustion. Generators are the simplest way to write a custom iterator without manually implementing __iter__/__next__.

A list materializes every element in memory at once, so its footprint grows with the number of items. A generator holds only its current state and computes each value on demand, so its memory use is roughly constant regardless of how many values it ultimately yields.

import sys
nums = [n * n for n in range(1_000_000)]   # ~8 MB list, built eagerly
gen  = (n * n for n in range(1_000_000))   # lazy — tiny, fixed size

sys.getsizeof(nums)   # large
sys.getsizeof(gen)    # ~100 bytes, regardless of range

This makes generators ideal for large or streaming data — reading a multi-gigabyte file line by line, or a pipeline of transformations — where you'd never want the whole dataset in RAM. The trade-off: you can only iterate a generator once, and you can't index or len() it.

They share syntax but differ in their brackets and behaviour. A list comprehension uses [...] and builds the entire list eagerly. A generator expression uses (...) and produces a lazy iterator that yields values one at a time, computing nothing until consumed.

lc = [x * 2 for x in range(5)]   # [0, 2, 4, 6, 8] — built now
ge = (x * 2 for x in range(5))   # <generator object> — built on demand

# parentheses are optional when it's the sole argument:
total = sum(x * 2 for x in range(5))   # streams — no temp list

Prefer a generator expression when feeding an aggregator like sum, max, any, or join over a large source — it avoids creating a throwaway list. Use a list comprehension when you need the full result repeatedly, want to index it, or need len().

yield from <iterable> delegates to a sub-iterator: it yields every value from the iterable as if you'd written a loop of yields, but also transparently forwards send, throw, and the sub-generator's return value. It's the clean way to compose or flatten generators.

def chain(*iterables):
    for it in iterables:
        yield from it          # vs: for x in it: yield x

list(chain([1, 2], (3, 4)))    # [1, 2, 3, 4]

def sub():
    yield 1
    return 99                  # captured by the delegator
def main():
    result = yield from sub()  # result == 99

Beyond saving a loop, yield from is what makes generator delegation and coroutine composition possible. Rule of thumb: use it whenever you want one generator to fully drain another.

Because generators are lazy, the body only advances when a value is requested — so an unbounded loop is fine: it never tries to produce all values at once. You control termination from the consumer side, by stopping iteration whenever you've taken enough.

def naturals():
    n = 0
    while True:        # infinite — but harmless
        yield n
        n += 1

from itertools import islice
list(islice(naturals(), 5))   # [0, 1, 2, 3, 4] — take just 5

This underpins itertools.count, cycle, and repeat, and lets you model streams elegantly. The danger is forgetting to bound the consumer: list(naturals()) or for x in naturals(): print(x) will run forever — pair infinite generators with islice, a break, or takewhile.

send(value) resumes a paused generator and makes the yield expression evaluate to value, turning the generator into a two-way coroutine. You must first "prime" it (advance to the first yield) with next() or send(None).

def accumulator():
    total = 0
    while True:
        x = yield total      # receives value sent in
        total += x

acc = accumulator()
next(acc)            # prime -> yields 0
acc.send(10)         # 10
acc.send(5)          # 15

Rule of thumb: use send for stateful coroutines that consume pushed values; always prime with next() before the first send(non-None).

A return in a generator stops iteration and its value becomes the StopIteration.value — not yielded. You normally retrieve it via yield from, or by catching StopIteration.

def gen():
    yield 1
    yield 2
    return "done"        # not yielded

g = gen()
next(g); next(g)
try:
    next(g)
except StopIteration as e:
    e.value              # 'done'

def wrapper():
    result = yield from gen()   # result == 'done'

Rule of thumb: return x in a generator sets StopIteration.value; capture it with yield from, not by expecting a yielded item.

close() raises GeneratorExit inside the paused generator so it can run cleanup (e.g. in a finally). throw(exc) injects an exception at the current yield, letting the generator handle or propagate it.

def worker():
    try:
        while True:
            x = yield
            print("got", x)
    finally:
        print("cleanup")     # runs on close()

w = worker(); next(w)
w.send(1)        # got 1
w.close()        # raises GeneratorExit -> prints "cleanup"

Rule of thumb: put resource cleanup in a finally so close() (and GC) can release it; throw() is for signaling errors into a coroutine.

Chain generators so each stage lazily consumes the previous one — data flows item-by-item with constant memory, never materializing intermediate lists. Great for large files and streams.

def read(path):
    with open(path) as f:
        for line in f:
            yield line.rstrip()

def grep(lines, term):
    for line in lines:
        if term in line:
            yield line

def upper(lines):
    for line in lines:
        yield line.upper()

for line in upper(grep(read("log.txt"), "ERROR")):
    print(line)        # streamed end to end

Rule of thumb: compose small generator stages for memory-efficient pipelines; each stage pulls one item at a time from the one before it.

A generator is its own iterator — it has no stored collection to restart from, only a current execution position. Once exhausted (or partially consumed), there's no "rewind"; you must create a new generator to iterate again.

g = (x for x in range(3))
list(g)        # [0, 1, 2]
list(g)        # [] — already exhausted

sum_, max_ = sum(g), max(g)   # BUG if g is consumed by the first call
data = list(range(3))         # materialize if you need multiple passes

Rule of thumb: generators are single-pass; if you need to iterate twice (or use both sum and max), store the values in a list first or rebuild the generator.

Laziness means you get the first result immediately without computing the rest — time-to-first-item is low and you can stop early. With an eager list you pay the full cost upfront even if you only need one match.

def find_first(it, pred):
    for x in it:
        if pred(x):
            return x        # stops as soon as found

# generator computes only until the match:
find_first((expensive(n) for n in range(10**9)), lambda v: v > 100)

Rule of thumb: generators let you short-circuit — combine with next(), any, or a break to avoid computing values you'll never use.

A generator function captures all the iteration state in local variables and the pause point automatically — no manual self.index bookkeeping, no explicit StopIteration. It's far less code for the same iterator behavior.

# verbose class iterator:
class Countdown:
    def __init__(self, n): self.n = n
    def __iter__(self): return self
    def __next__(self):
        if self.n <= 0: raise StopIteration
        self.n -= 1; return self.n + 1

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

Rule of thumb: reach for a generator function for almost all custom iteration; use a class only when you need extra methods/attributes alongside iteration.

A generator keeps its own frame (locals, instruction pointer) alive across suspensions. When paused at a yield, all locals retain their values; the next next() resumes exactly where it left off — like a saved stack frame.

def running_total():
    total = 0
    for x in [10, 20, 30]:
        total += x         # `total` survives across yields
        yield total

list(running_total())      # [10, 30, 60]

Rule of thumb: each generator instance has independent, persistent local state — that's why two calls to the same generator function don't interfere.

For recursive structures (trees, nested lists), yield from lets a generator delegate to a recursive call cleanly, flattening the structure without manual loops at each level.

def flatten(items):
    for item in items:
        if isinstance(item, list):
            yield from flatten(item)   # recurse and forward all values
        else:
            yield item

list(flatten([1, [2, [3, 4], 5], 6]))   # [1, 2, 3, 4, 5, 6]

Rule of thumb: yield from recursive_call(...) is the idiom for recursively walking nested data lazily — no accumulator list needed.

Passing one generator to two consumers means the second sees nothing — the first drained it. This bites when you compute several aggregates over the same source.

gen = (n for n in range(5))
total = sum(gen)     # 10 — consumes gen
count = len(list(gen))   # 0! — already exhausted
avg = total / count      # ZeroDivisionError

data = list(range(5))    # fix: materialize once
total, count = sum(data), len(data)

Rule of thumb: if you need multiple passes or several aggregates, convert the generator to a list first (or use itertools.tee for limited re-iteration).

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel