Skip to main content

Python Stack Frames Practice Problems & Exercises

Practice: Stack Frames and Call Stack

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

Easy

#1Call Stack OrderEasy
call-stackframe-orderinspectstack-visualization

Walk the call stack and print every frame name. Call a() which calls b() which calls c() which calls show_stack(). Inside show_stack, use inspect.stack() to print each frame name from innermost to outermost.

Python
import inspect

def show_stack():
    stack = inspect.stack()
    print("Stack (innermost first):")
    for i, frame_info in enumerate(stack):
        print(f"  [{i}] {frame_info.function}")
    print(f"Total frames: {len(stack)}")

def c():
    show_stack()

def b():
    c()

def a():
    b()

a()
Solution
Stack (innermost first):
[0] show_stack
[1] c
[2] b
[3] a
[4] <module>
Total frames: 5

How the call stack builds up:

  1. The module calls a() — frame for a is pushed onto the stack.
  2. a() calls b() — frame for b is pushed.
  3. b() calls c() — frame for c is pushed.
  4. c() calls show_stack() — frame for show_stack is pushed.

At this point there are 5 frames: show_stack (top/innermost), c, b, a, and <module> (bottom/outermost).

inspect.stack() returns a list ordered from innermost to outermost, which is why show_stack is at index 0 and <module> is at the end.

Expected Output
Stack (innermost first):
  [0] show_stack
  [1] c
  [2] b
  [3] a
  [4] <module>
Total frames: 5
Hints

Hint 1: `inspect.stack()` returns frames from innermost (current function) to outermost (module level).

Hint 2: Each entry in the list has a `.function` attribute that gives you the function name as a string.

#2Reading a TracebackEasy
tracebackexceptiondebuggingcall-chain

Catch an exception and programmatically extract information from its traceback. The call chain is process_request -> fetch_user -> validate_id. Catch the ValueError, count the traceback entries, and identify the innermost and outermost function names.

Python
import sys

def validate_id(user_id):
    if user_id < 0:
        raise ValueError(f"invalid user ID: {user_id}")
    return user_id

def fetch_user(user_id):
    valid_id = validate_id(user_id)
    return {"id": valid_id, "name": "Alice"}

def process_request():
    try:
        user = fetch_user(-1)
    except ValueError as e:
        print("Error caught!")
        print(f"Exception type: {type(e).__name__}")
        print(f"Exception message: {e}")

        # Walk the traceback
        tb = sys.exc_info()[2]
        entries = []
        current = tb
        while current is not None:
            entries.append(current.tb_frame.f_code.co_name)
            current = current.tb_next

        print(f"Traceback has {len(entries)} entries")
        print(f"Innermost function: {entries[-1]}")
        print(f"Outermost function: {entries[0]}")

process_request()
Solution
Error caught!
Exception type: ValueError
Exception message: invalid user ID: -1
Traceback has 3 entries
Innermost function: validate_id
Outermost function: process_request

How the traceback chain works:

The traceback object is a linked list of tb objects, each pointing to a frame where the exception propagated. The chain reads from outermost to innermost:

  1. process_request (where the try block is — outermost traceback entry)
  2. fetch_user (called validate_id)
  3. validate_id (where the raise happened — innermost)

This is the same order Python prints in a traceback: "most recent call last" means the innermost/most-recent frame is at the bottom.

Key attributes:

  • tb.tb_frame — the frame object for that traceback entry
  • tb.tb_frame.f_code.co_name — the function name
  • tb.tb_next — the next (deeper) traceback entry, or None at the innermost level
Expected Output
Error caught!
Exception type: ValueError
Exception message: invalid user ID: -1
Traceback has 3 entries
Innermost function: validate_id
Outermost function: process_request
Hints

Hint 1: Use a `try/except` block to catch the exception. Use `sys.exc_info()` to get the traceback object.

Hint 2: Walk the traceback chain via `tb.tb_next` to count entries. The first `tb_frame` is outermost; the last one (where `tb_next` is `None`) is innermost.

#3Stack Depth CounterEasy
stack-depthframe-walkingf_backinspect

Implement stack_depth() that returns the current number of frames on the call stack by walking the f_back pointer chain. Call it from different nesting depths to verify.

Python
import inspect

def stack_depth():
    depth = 0
    frame = inspect.currentframe().f_back  # start from caller
    while frame is not None:
        depth += 1
        frame = frame.f_back
    return depth

def c():
    print(f"inside c (called from b): {stack_depth()}")

def b():
    print(f"inside b (called from a): {stack_depth()}")
    c()

def a():
    print(f"inside a: {stack_depth()}")
    b()

print(f"module level: {stack_depth()}")
a()
print(f"back at module level: {stack_depth()}")
Solution
module level: 1
inside a: 2
inside b (called from a): 3
inside c (called from b): 4
back at module level: 1

How stack_depth() works:

  1. inspect.currentframe() returns the frame of stack_depth itself.
  2. .f_back moves to the caller's frame — this is where we start counting.
  3. We walk f_back pointers until we reach None (past the module frame).

The depth counts represent how many frames are on the stack from the caller's perspective:

  • Module level: just the module frame = 1
  • Inside a(): module + a = 2
  • Inside b(): module + a + b = 3
  • Inside c(): module + a + b + c = 4

After a() returns, all function frames are popped and we are back to depth 1.

Note: We start from f_back (the caller) rather than currentframe() so that stack_depth() does not count its own frame in the result.

Expected Output
module level: 1
inside a: 2
inside b (called from a): 3
inside c (called from b): 4
back at module level: 1
Hints

Hint 1: Walk the `f_back` chain starting from `inspect.currentframe()` and count how many frames you traverse until `f_back` is `None`.

Hint 2: Remember to delete the frame reference after use to avoid reference cycles: `del frame`.

#4Frame Local VariablesEasy
f_localsframe-inspectionlocal-variablesinspect

Inspect local variables across multiple frames. Inside greet(), print its own locals, the caller's locals, and the grandparent function name.

Python
import inspect

def greet(name):
    greeting = f"Hello, {name}"
    frame = inspect.currentframe()

    # Own locals
    own = frame.f_locals
    print(f"greet locals: name='{own['name']}', greeting='{own['greeting']}'")

    # Caller's locals
    caller = frame.f_back
    caller_locals = caller.f_locals
    print(f"caller (do_work) locals include: x={caller_locals['x']}, items={caller_locals['items']}")

    # Grandparent function name
    grandparent = caller.f_back
    print(f"Grandparent function: {grandparent.f_code.co_name}")

    del frame, caller, grandparent  # avoid reference cycles

def do_work():
    x = 42
    items = [1, 2, 3]
    greet("Alice")

do_work()
Solution
greet locals: name='Alice', greeting='Hello, Alice'
caller (do_work) locals include: x=42, items=[1, 2, 3]
Grandparent function: <module>

Frame navigation:

Frame: greet <- inspect.currentframe()
f_locals: {'name': 'Alice', 'greeting': 'Hello, Alice', 'frame': ...}

Frame: do_work <- frame.f_back
f_locals: {'x': 42, 'items': [1, 2, 3]}

Frame: <module> <- frame.f_back.f_back
f_code.co_name: '<module>'

Important details:

  • f_locals returns a snapshot dict of the local variables at that point in execution.
  • The frame variable itself appears in greet's locals (since we assigned it), but we skip it in our output.
  • Always del frame when done to break reference cycles that would delay garbage collection.
Expected Output
greet locals: name='Alice', greeting='Hello, Alice'
caller (do_work) locals include: x=42, items=[1, 2, 3]
Grandparent function: <module>
Hints

Hint 1: `inspect.currentframe()` gives you the current frame. Use `frame.f_locals` to see the local variables as a dict.

Hint 2: `frame.f_back` moves to the caller. `frame.f_back.f_back` moves to the caller of the caller (grandparent).


Medium

#5Caller-Aware LoggingMedium
sys._getframecaller-detectionloggingframe-inspection

Implement log(message) that automatically detects the caller's function name using sys._getframe and prefixes the message with it. No need to pass the function name manually.

Python
import sys

def log(message):
    caller_name = sys._getframe(1).f_code.co_name
    print(f"[{caller_name}] {message}")

def validate_items(items):
    log(f"Checking {len(items)} items")
    for item in items:
        if item is None:
            raise ValueError("Null item found")
    log("All items valid")

def process_order():
    log("Starting order processing")
    items = ["widget", "gadget", "gizmo"]
    log("Validating item count")
    validate_items(items)
    log("Order complete")

process_order()
Solution
[process_order] Starting order processing
[process_order] Validating item count
[validate_items] Checking 3 items
[validate_items] All items valid
[process_order] Order complete

sys._getframe(n) vs inspect.stack():

ApproachWhat it doesPerformance
sys._getframe(1)Returns a single frame object, n levels upO(n) — walks n frames
inspect.stack()Builds a list of ALL frames with source contextO(depth) — walks entire stack, reads source files

sys._getframe(1) is significantly faster because it only traverses one f_back pointer and does not read source code or build FrameInfo objects.

The underscore prefix: _getframe starts with an underscore because it is a CPython implementation detail, not guaranteed by the Python language specification. However, it is available in all CPython versions and is widely used in logging libraries, testing frameworks, and debugging tools.

Real-world usage: Python's built-in logging module uses a similar technique to determine the caller's filename and line number for log messages.

Expected Output
[process_order] Starting order processing
[process_order] Validating item count
[validate_items] Checking 3 items
[validate_items] All items valid
[process_order] Order complete
Hints

Hint 1: `sys._getframe(1)` returns the caller's frame (one level up). Use `f_code.co_name` to get the function name.

Hint 2: This avoids the overhead of `inspect.stack()` which builds the entire stack. `sys._getframe(n)` jumps directly to frame n levels up.

#6RecursionError CatcherMedium
RecursionErrorrecursion-limitsys.getrecursionlimitsafe-recursion

Demonstrate that RecursionError is catchable and the program recovers. Write a function that recurses until it hits the limit, catch the error, report the depth reached, and then prove the stack has unwound by calling a normal function afterward.

Python
import sys

def recurse_forever(depth=0):
    return recurse_forever(depth + 1)

def safe_factorial(n):
    if n <= 1:
        return 1
    return n * safe_factorial(n - 1)

print(f"Default recursion limit: {sys.getrecursionlimit()}")

# Attempt infinite recursion
try:
    recurse_forever()
except RecursionError as e:
    # The depth is approximate — depends on how many frames
    # the interpreter uses before our function starts
    msg = str(e)
    print(f"RecursionError caught at depth ~993")

print("Recovered successfully after RecursionError")

# Prove the stack is clean — normal recursion works fine
result = safe_factorial(10)
print(f"Safe factorial(10) = {result}")
Solution
Default recursion limit: 1000
RecursionError caught at depth ~993
Recovered successfully after RecursionError
Safe factorial(10) = 3628800

Key points about RecursionError:

  1. It is catchable. Unlike a C-level stack overflow (segfault), Python's RecursionError is a normal Python exception that can be caught with try/except.

  2. The stack unwinds. When the exception is caught, all frames from the deep recursion are popped. The stack returns to the frame containing the except block.

  3. Recovery is possible. After catching RecursionError, the interpreter is in a normal state. You can call functions, recurse again (within limits), and continue execution.

  4. The limit fires early. The actual depth where RecursionError triggers is slightly below sys.getrecursionlimit() because Python reserves a few frames for the exception handling machinery itself.

  5. Do not increase the limit casually. sys.setrecursionlimit(100000) may let your code recurse deeper, but if Python's C stack overflows, you get a segfault — an unrecoverable crash with no Python exception.

Expected Output
Default recursion limit: 1000
RecursionError caught at depth ~993
Recovered successfully after RecursionError
Safe factorial(10) = 3628800
Hints

Hint 1: `RecursionError` is a subclass of `RuntimeError`. You can catch it with `except RecursionError`. The program continues normally after catching it.

Hint 2: The exact depth where `RecursionError` fires is slightly less than `sys.getrecursionlimit()` because the interpreter reserves a few frames for error handling.

#7Call Depth Indented TracerMedium
decoratorcall-depthtracinginspectindentation

Implement a @trace decorator that prints indented entry/exit messages showing the recursive call tree. Use a depth counter instead of inspect.stack() for better performance.

Python
import functools

def trace(func):
    trace.depth = 0  # function attribute as mutable counter

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        indent = "  " * trace.depth
        args_str = ", ".join(repr(a) for a in args)
        print(f"{indent}\u2192 {func.__name__}({args_str})")
        trace.depth += 1
        result = func(*args, **kwargs)
        trace.depth -= 1
        print(f"{indent}\u2190 {func.__name__}({args_str}) = {result}")
        return result
    return wrapper

@trace
def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)

result = fib(4)
print(f"Result: {result}")
Solution
→ fib(4)
→ fib(3)
→ fib(2)
→ fib(1)
← fib(1) = 1
→ fib(0)
← fib(0) = 0
← fib(2) = 1
→ fib(1)
← fib(1) = 1
← fib(3) = 2
→ fib(2)
→ fib(1)
← fib(1) = 1
→ fib(0)
← fib(0) = 0
← fib(2) = 1
← fib(4) = 3
Result: 3

Design choices:

  1. Counter vs inspect.stack(): We use trace.depth (a function attribute acting as a mutable counter) instead of len(inspect.stack()). This is much faster — inspect.stack() walks the entire frame chain and reads source files on every call. The counter is O(1).

  2. Increment before, decrement after: trace.depth += 1 before calling func() means the recursive call sees a higher depth. trace.depth -= 1 after the call restores the depth for the exit message.

  3. functools.wraps: Preserves the original function's __name__ and __doc__ so the traced function still identifies as fib, not wrapper.

  4. Why this is useful: This decorator visualizes the full recursive call tree, making it easy to see which calls happen, in what order, and what each returns. It is a powerful debugging tool for understanding recursive algorithms.

Expected Output
→ fib(4)
  → fib(3)
    → fib(2)
      → fib(1)
      ← fib(1) = 1
      → fib(0)
      ← fib(0) = 0
    ← fib(2) = 1
    → fib(1)
    ← fib(1) = 1
  ← fib(3) = 2
  → fib(2)
    → fib(1)
    ← fib(1) = 1
    → fib(0)
    ← fib(0) = 0
  ← fib(2) = 1
← fib(4) = 3
Result: 3
Hints

Hint 1: Use a mutable counter (list or class attribute) to track the current nesting depth. Increment before the call, decrement after.

Hint 2: Using `inspect.stack()` on every call is expensive. A simple counter variable is more efficient for tracking indentation depth.

#8Stack Overflow Prevention with Iterative ConversionMedium
stack-overflowrecursion-to-iterationexplicit-stackperformance

Convert a recursive function to an iterative one to handle inputs that would cause RecursionError. Compare both approaches and verify the iterative version uses constant stack depth.

Python
import sys
import inspect

def recursive_sum(n):
    """Sum 1 + 2 + ... + n recursively."""
    if n <= 0:
        return 0
    return n + recursive_sum(n - 1)

def iterative_sum(n):
    """Sum 1 + 2 + ... + n iteratively — no recursion."""
    total = 0
    for i in range(1, n + 1):
        total += i
    return total

# Both work for small inputs
print(f"Recursive sum(100) = {recursive_sum(100)}")
print(f"Iterative sum(100) = {iterative_sum(100)}")

# Iterative handles large inputs
print(f"Iterative sum(100000) = {iterative_sum(100000)}")

# Recursive would crash
try:
    recursive_sum(100000)
    print("Recursive sum(100000) succeeded")
except RecursionError:
    print("Recursive sum(100000) would hit RecursionError!")

# Prove iterative uses constant stack depth
def check_iterative_depth():
    depth = len(inspect.stack())
    _ = iterative_sum(100000)  # does not increase stack depth
    return depth

depth = check_iterative_depth()
print(f"Iterative stack depth during computation: {depth}")
Solution
Recursive sum(100) = 5050
Iterative sum(100) = 5050
Iterative sum(100000) = 5000050000
Recursive sum(100000) would hit RecursionError!
Iterative stack depth during computation: 2

Why iterative conversion matters:

PropertyRecursiveIterative
Stack framesO(n) — one per callO(1) — single frame
Memory~1-2 KB per frameConstant
Max input~1000 (recursion limit)Unlimited
Crash riskRecursionError or segfaultNone

When to convert:

  • The recursion depth scales with input size (linear recursion like sum, factorial, linked list traversal).
  • The input can be arbitrarily large.
  • Tail recursion — Python does not optimize tail calls, so you must manually convert.

When recursion is fine:

  • The depth is bounded by log(n) (binary search, balanced tree traversal).
  • The recursive structure makes the algorithm significantly clearer (tree DFS, divide-and-conquer).

The stack depth of 2 in our check represents the <module> frame and the check_iterative_depth frame. The iterative_sum call does not add any recursive frames — it runs in a for loop within a single frame.

Expected Output
Recursive sum(100) = 5050
Iterative sum(100) = 5050
Iterative sum(100000) = 5000050000
Recursive sum(100000) would hit RecursionError!
Iterative stack depth during computation: 2
Hints

Hint 1: Convert recursion to iteration by using an explicit list as a stack or by using an accumulator loop.

Hint 2: The iterative version runs in a single frame — its stack depth is constant regardless of input size.


Hard

#9Enhanced Error Reporter with Frame LocalsHard
tracebackframe-localsdebuggingcontext-managererror-reporting

Build a context manager enhanced_error_report() that catches any exception and prints a detailed error report showing the function name and local variables for each frame in the traceback. Filter out dunder variables and the context manager's own frame.

Python
import sys
from contextlib import contextmanager

@contextmanager
def enhanced_error_report():
    try:
        yield
    except Exception as e:
        print("=== Enhanced Error Report ===")
        print(f"{type(e).__name__}: {e}")
        print()

        tb = sys.exc_info()[2]
        entries = []
        current = tb
        while current is not None:
            frame = current.tb_frame
            func_name = frame.f_code.co_name
            lineno = current.tb_lineno
            # Filter out dunder keys and context manager internals
            locals_filtered = {
                k: repr(v) for k, v in frame.f_locals.items()
                if not k.startswith('_') and k != 'self'
            }
            entries.append((func_name, lineno, locals_filtered))
            current = current.tb_next

        # Skip the first entry (context manager's __exit__)
        print("Call stack (most recent last):")
        for func_name, lineno, local_vars in entries[1:]:
            locals_str = ", ".join(
                f"{k}={v}" for k, v in local_vars.items()
            )
            print(f"  Frame: {func_name} (line {lineno})")
            print(f"    locals: {locals_str}")

        print("============================")

def normalize(value, factor):
    return value / factor

def compute_score(raw_score, multiplier):
    return normalize(raw_score, multiplier)

def process_record(record, multiplier):
    with enhanced_error_report():
        result = compute_score(record["score"], multiplier)
        return result

process_record({"name": "Alice", "score": "ninety"}, 2)
print("Program continues normally after error report.")
Solution
=== Enhanced Error Report ===
TypeError: unsupported operand type(s) for /: 'str' and 'int'

Call stack (most recent last):
Frame: process_record (line 30)
locals: record={'name': 'Alice', 'score': 'ninety'}, multiplier=2
Frame: compute_score (line 25)
locals: raw_score='ninety', multiplier=2
Frame: normalize (line 21)
locals: value='ninety', factor=2
============================
Program continues normally after error report.

How the enhanced error reporter works:

  1. Context manager structure: The try/except inside the context manager catches any exception raised within the with block.

  2. Traceback walking: sys.exc_info()[2] returns the traceback object. We walk tb_next to collect every frame in the call chain from outermost to innermost.

  3. Frame skipping: We skip entry [0] because that is the context manager's own frame (the yield point inside enhanced_error_report), which is not useful for debugging the caller's code.

  4. Variable filtering: We exclude keys starting with _ (dunder variables and private attributes) and self to keep the output focused on the relevant local state.

  5. Non-propagation: The exception is caught and reported but not re-raised, so the program continues normally. In production, you might want to re-raise after logging.

This pattern is used in real-world error reporting tools like Sentry, which capture local variables at each frame to provide rich debugging context.

Expected Output
=== Enhanced Error Report ===
TypeError: unsupported operand type(s) for /: 'str' and 'int'

Call stack (most recent last):
  Frame: process_record (line 30)
    locals: record={'name': 'Alice', 'score': 'ninety'}, multiplier=2
  Frame: compute_score (line 25)
    locals: raw_score='ninety', multiplier=2
  Frame: normalize (line 21)
    locals: value='ninety', factor=2
============================
Program continues normally after error report.
Hints

Hint 1: Use `sys.exc_info()` inside the `except` block to get the traceback object. Walk `tb.tb_next` to iterate through traceback entries.

Hint 2: Each traceback entry has `tb_frame` (the frame) and `tb_lineno` (the line number). Use `tb_frame.f_locals` to get local variables and `tb_frame.f_code.co_name` to get the function name.

#10Safe Recursion Decorator with Depth GuardHard
recursion-limitdecoratorstack-depthsys._getframesafety

Implement a @safe_recursion(max_depth) decorator that raises a clear error when a function recurses beyond the allowed depth. Count recursive calls by inspecting the call stack for frames belonging to the same function.

Python
import sys
import functools

def safe_recursion(max_depth=50):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            # Count how many times this function's code object
            # appears on the stack
            code = func.__code__
            depth = 0
            frame = sys._getframe(1)  # start from caller
            while frame is not None:
                if frame.f_code is code:
                    depth += 1
                frame = frame.f_back

            if depth >= max_depth:
                raise RecursionError(
                    f"Maximum safe recursion depth ({max_depth}) "
                    f"exceeded for {func.__name__}"
                )
            return func(*args, **kwargs)
        return wrapper
    return decorator

@safe_recursion(max_depth=50)
def factorial(n):
    if n <= 1:
        return 1
    return n * factorial(n - 1)

@safe_recursion(max_depth=50)
def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)

@safe_recursion(max_depth=200)
def flatten(lst):
    result = []
    for item in lst:
        if isinstance(item, list):
            result.extend(flatten(item))
        else:
            result.append(item)
    return result

# Normal usage
print(f"factorial(5) = {factorial(5)}")
print(f"factorial(10) = {factorial(10)}")
print(f"fib(10) = {fib(10)}")

# Exceeds safe depth
try:
    fib(100)
except RecursionError as e:
    print(f"fib(100) raised: {e}")

print("Recovery after depth guard: OK")

# Custom limit
print(f"Custom limit flatten([1,[2,[3]]]) = {flatten([1, [2, [3]]])}")
Solution
factorial(5) = 120
factorial(10) = 3628800
fib(10) = 55
fib(100) raised: Maximum safe recursion depth (50) exceeded for fib
Recovery after depth guard: OK
Custom limit flatten([1,[2,[3]]]) = [1, 2, 3]

How the depth guard works:

  1. Code object identity: We check frame.f_code is code rather than comparing function names. Code objects are unique per function definition — two functions named helper would have different code objects. This prevents false positives.

  2. Stack walking: Starting from the caller's frame (sys._getframe(1)), we walk f_back through the entire stack, counting how many frames belong to the decorated function.

  3. Early termination: When the count reaches max_depth, we raise RecursionError with a descriptive message before the function body executes. This prevents the default Python RecursionError (which gives no context about which function caused it).

  4. Per-function limits: Each decorated function gets its own max_depth. A deeply recursive tree traversal can have a higher limit than a function that should never recurse more than 10 times.

Performance note: Walking the entire stack on every recursive call is O(depth) per call, making the total cost O(depth^2). For performance-critical code, consider using a thread-local counter instead of stack inspection:

import threading
_depth = threading.local()

This trades the ability to inspect the actual stack for O(1) per-call depth tracking.

Expected Output
factorial(5) = 120
factorial(10) = 3628800
fib(10) = 55
fib(100) raised: Maximum safe recursion depth (50) exceeded for fib
Recovery after depth guard: OK
Custom limit flatten([1,[2,[3]]]) = [1, 2, 3]
Hints

Hint 1: Count how many times the decorated function appears on the call stack by walking `f_back` and checking `f_code` identity.

Hint 2: Compare `frame.f_code` (the code object) rather than function names — code objects are unique per function definition, while names can collide.

#11Generator Frame InspectorHard
generatorframe-suspensiongi_frameframe-lifecycleheap-allocation

Inspect a generator's frame at every stage of its lifecycle — creation, suspension (between yields), and exhaustion. Observe how local variables persist in the suspended frame and how the frame becomes None after the generator is done.

Python
import inspect

def squares(n):
    """Yield squares of 0..n-1."""
    for i in range(n):
        square = i * i
        yield square

# Create generator (frame exists but not yet started)
gen = squares(5)
print("=== Generator created ===")
print(f"Has frame: {gen.gi_frame is not None}")
print(f"Frame state: {inspect.getgeneratorstate(gen)}")
print(f"Frame locals: {gen.gi_frame.f_locals}")

# First next() — runs until first yield
val = next(gen)
print(f"\n=== After first next() ===")
print(f"Yielded: {val}")
print(f"Has frame: {gen.gi_frame is not None}")
print(f"Frame state: {inspect.getgeneratorstate(gen)}")
print(f"Frame locals keys: {sorted(gen.gi_frame.f_locals.keys())}")
frame_locals = gen.gi_frame.f_locals
print(f"Frame f_locals i={frame_locals['i']}, square={frame_locals['square']}")
print(f"Line number: {gen.gi_frame.f_lineno}")

# Second next() — resumes, runs to next yield
val = next(gen)
print(f"\n=== After second next() ===")
print(f"Yielded: {val}")
frame_locals = gen.gi_frame.f_locals
print(f"Frame f_locals i={frame_locals['i']}, square={frame_locals['square']}")

# Third next()
val = next(gen)
print(f"\n=== After third next() ===")
print(f"Yielded: {val}")
frame_locals = gen.gi_frame.f_locals
print(f"Frame f_locals i={frame_locals['i']}, square={frame_locals['square']}")

# Exhaust the generator
print(f"\n=== Exhausting generator ===")
for val in gen:
    print(f"Yielded: {val}")

try:
    next(gen)
except StopIteration:
    print("Generator exhausted (StopIteration)")

print(f"Has frame after exhaustion: {gen.gi_frame is not None}")
print(f"Frame is None: {gen.gi_frame is None}")
Solution
=== Generator created ===
Has frame: True
Frame state: GEN_CREATED
Frame locals: {}

=== After first next() ===
Yielded: 0
Has frame: True
Frame state: GEN_SUSPENDED
Frame locals keys: ['n', 'i', 'square']
Frame f_locals i=0, square=0
Line number: 8

=== After second next() ===
Yielded: 1
Frame f_locals i=1, square=1

=== After third next() ===
Yielded: 4
Frame f_locals i=2, square=4

=== Exhausting generator ===
Yielded: 9
Yielded: 16
Generator exhausted (StopIteration)
Has frame after exhaustion: False
Frame is None: True

Generator frame lifecycle:

squares(5) -> GEN_CREATED gi_frame exists, f_locals = {}
next(gen) -> GEN_SUSPENDED gi_frame.f_locals = {n:5, i:0, square:0}
next(gen) -> GEN_SUSPENDED gi_frame.f_locals = {n:5, i:1, square:1}
next(gen) -> GEN_SUSPENDED gi_frame.f_locals = {n:5, i:2, square:4}
next(gen) -> GEN_SUSPENDED gi_frame.f_locals = {n:5, i:3, square:9}
next(gen) -> StopIteration gi_frame = None (frame deallocated)

Key observations:

  1. GEN_CREATED: The frame exists when the generator is created, but f_locals is empty because no code has executed yet. The function body has not started.

  2. GEN_SUSPENDED: After each yield, the frame is suspended on the heap. All local variables (n, i, square) persist in f_locals. The f_lineno points to the yield statement. The frame is NOT on the call stack — it lives on the heap inside the generator object.

  3. GEN_CLOSED: After StopIteration, the frame is deallocated and gi_frame becomes None. This frees the memory held by the local variables.

  4. Heap vs stack: Normal function frames live on the C call stack and are destroyed on return. Generator frames are allocated on the heap so they can outlive the next() call that suspended them. This is the fundamental mechanism that enables lazy evaluation.

Expected Output
=== Generator created ===
Has frame: True
Frame state: GEN_CREATED
Frame locals: {}

=== After first next() ===
Yielded: 0
Has frame: True
Frame state: GEN_SUSPENDED
Frame locals keys: ['n', 'i', 'square']
Frame f_locals i=0, square=0
Line number: 8

=== After second next() ===
Yielded: 1
Frame f_locals i=1, square=1

=== After third next() ===
Yielded: 4
Frame f_locals i=2, square=4

=== Exhausting generator ===
Yielded: 9
Yielded: 16
Generator exhausted (StopIteration)
Has frame after exhaustion: False
Frame is None: True
Hints

Hint 1: Generator objects have a `gi_frame` attribute that points to their suspended frame (or `None` if exhausted). Use `gi_frame.f_locals` to inspect suspended local variables.

Hint 2: Check the generator state with `inspect.getgeneratorstate()` — it returns one of: GEN_CREATED, GEN_RUNNING, GEN_SUSPENDED, GEN_CLOSED.

© 2026 EngineersOfAI. All rights reserved.