Python Decorators and Generators: yield, Closures and the Trace-the-Output Interview Questions

See decorators and generators as functions that retain state. Trace a wrapped call, expand @repeat(3), and follow countdown(3) through StopIteration.

KnowledgeGate Team

Exam prep & CS education

Updated 15 Jul 20266 min read

Decorators and generators can look like unrelated pieces of magic syntax. They become simpler when you notice the shared idea: a function remembers something. A decorator's wrapper remembers the function it wraps, while a generator remembers the suspended state of its own execution.

Once the closure underneath is clear, interview questions become mechanical traces. Expand the @ line, follow each function call, and mark where a generator pauses.

Python closures: the idea underneath decorators and generators

A closure is an inner function that retains access to variables in its enclosing function even after that outer function has returned.

def make_adder(n):
    def add(x):
        return x + n
    return add

add5 = make_adder(5)
print(add5(3))  # 8

make_adder(5) has finished, but the returned add function still has access to n. The call add5(3) therefore evaluates 3 + 5 and returns 8.

The important phrase is retained access, not copied value. A closure normally resolves the captured variable when the inner function runs. That is why a closure built inside a loop reads the loop variable at call time, not at creation time.

Closures are useful for configuration without global state. make_adder(5) and make_adder(10) produce separate functions with different enclosing environments, even though both use the same add definition.

A wrapper leans on the ordinary parameter and scope rules: *args and **kwargs for forwarding, and LEGB name lookup for the function it captured. Those rules, along with the mutable default trap, are worked through in Python functions, args, kwargs, mutable defaults and scope.

Decorators are closures plus @ syntax

A decorator receives a function and returns a replacement callable. This code:

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

means exactly:

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

add = announce(add)

Here is the decorator:

from functools import wraps

def announce(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("calling")
        result = func(*args, **kwargs)
        print("done")
        return result
    return wrapper

Now trace add(2, 3):

  1. The name add refers to wrapper after decoration.

  2. wrapper prints calling.

  3. It calls the original add(2, 3), which returns 5.

  4. It stores 5 in result and prints done.

  5. It returns 5 to the caller.

The printed order is calling, then done. The return value is 5. These are separate outputs, which is why trace questions often ask both.

*args and **kwargs let the wrapper forward positional and keyword arguments without hard-coding the wrapped function's signature. @wraps(func) preserves metadata such as add.__name__ and its docstring. Without it, those attributes would describe wrapper instead.

Decorators that take arguments

A decorator such as @repeat(3) needs one extra function layer because Python must first process the number 3.

from functools import wraps

def repeat(times):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            result = None
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

The expansion is:

add = repeat(3)(add)

Read it from left to right. repeat(3) returns decorator. That decorator receives add and returns wrapper. A later call runs the original function three times. The three nested definitions are a factory, a decorator, and a wrapper.

Generators and yield, traced step by step

A function containing yield creates a generator when called. It does not execute the body immediately.

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

g = countdown(3)

At this point, no loop iteration has run. Now trace the calls:

  1. The first next(g) starts the function with n = 3, reaches yield n, returns 3, and pauses before n -= 1.

  2. The second next(g) resumes, changes n to 2, passes the loop test, yields 2, and pauses again.

  3. The third next(g) resumes, changes n to 1, and yields 1.

  4. The fourth next(g) resumes, changes n to 0, fails n > 0, reaches the end, and raises StopIteration.

The yielded sequence is therefore 3, 2, 1. There is no yielded zero because the condition is checked before the next yield.

A frame-by-frame timeline of countdown(3) yielding 3, 2, then 1 before next() raises StopIteration.

The generator's local variable n survives between calls. That retained execution frame is what makes a generator lazy and resumable.

Why generators matter and where they trap you

A list creates all its elements before you use them. A generator produces one value at a time as the consumer asks for it. That makes a generator suitable for a large input stream, a file processed line by line, or a transformation pipeline where materialising every intermediate value would be wasteful.

Compare the forms:

squares_list = [x * x for x in data]
squares_generator = (x * x for x in data)

The first builds a list. The second creates a lazy generator expression.

Remember four common traps:

  • A generator is exhausted after a complete pass. Iterating over the same generator again yields nothing.

  • Calling next() after exhaustion raises StopIteration.

  • Forgetting @wraps loses the wrapped function's useful metadata.

  • Closures created in a loop capture the loop variable, so they may all see its final value.

For example:

funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])  # [2, 2, 2]

Each lambda looks up the same i when called, after the loop has ended. Capture the current value as a default argument when that is the desired behaviour:

funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs])  # [0, 1, 2]

How interviews test decorators and generators

Interviewers ask you to trace the print order of a decorated call, expand @deco into ordinary assignment, predict the third next() result, write a call-counting decorator, or explain why a generator uses less memory than a materialised list.

The reliable method is to remove the shorthand. Replace @deco with f = deco(f). For a generator, write down the local variables at each pause and resume point. Do not mentally restart the function on every next() call.

The call-counting decorator is the one item on that list that asks you to write rather than trace. Keep the tally on the wrapper object itself:

from functools import wraps

def count_calls(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        wrapper.calls += 1
        return func(*args, **kwargs)
    wrapper.calls = 0
    return wrapper

@count_calls
def greet(name):
    return "hi " + name

greet("asha")
greet("ravi")
print(greet.calls)  # 2

wrapper.calls = 0 runs once, at decoration time, so each decorated function gets its own counter and no global is needed. After decoration the name greet refers to wrapper, which is why greet.calls reads the count. The printed value is 2.

Our question bank carries over 450 Python practice questions, and the functions and modules set is where closure, decorator and generator traces sit. Python interview questions for freshers puts output-tracing questions next to the core-language answers, data structures and complexity, and the coding-round staples.

The short version and next step

@deco means f = deco(f). A decorator is a closure over the wrapped function. A generator pauses at yield, keeps its local state, and resumes on the next next() call. For countdown(3), the values are 3, 2, and 1, followed by StopIteration.

Work through the functions and generators material in the Python Programming course, then take the same syntax into problem solving with DSA Using Python. The Coding and DSA courses page lists the wider set when you want to pair Python with data structures, algorithms and full coding rounds.