Stack Frames and Call Stack - Inside Python's Execution Model
Reading time: ~16 minutes | Level: Foundation → Engineering
Here is a question most Python programmers cannot answer:
import inspect
def c():
frame = inspect.currentframe()
print(frame.f_locals)
print(frame.f_back.f_locals)
def b():
x = 42
c()
def a():
name = "Alice"
b()
a()
What does each print output?
{} # c's local variables (only frame object)
{'x': 42} # b's local variables
Every active function call in Python has a frame - a data structure holding that call's local variables, the current bytecode instruction, and a pointer to the caller's frame. Understanding frames is understanding Python's execution model.
What You Will Learn
- What a stack frame (PyFrameObject) contains in CPython
- How the call stack grows and shrinks with each function call/return
- How to inspect frames with the
inspectmodule - How to read Python tracebacks from bottom to top
- Why deep recursion raises
RecursionError(frame memory limits) - How generators suspend and resume frames between yields
- How async/await stores coroutine frames on the heap
- How to write better error messages using frame inspection
Prerequisites
- Python functions: def, parameters, return (Lesson 01)
- Recursion basics (Lesson 10)
- Basic familiarity with Python bytecode is helpful but not required
What Is a Stack Frame?
Every time you call a Python function, the interpreter creates a frame - a CPython data structure (PyFrameObject) that holds everything needed to execute that function call:
Each frame also has a value stack - a small stack where bytecode operations push and pop operands. This is separate from the call stack.
The Call Stack
As functions call each other, frames chain together via f_back pointers. This chain is the call stack:
def c():
pass
def b():
c()
def a():
b()
a()
While c() is running, the call stack looks like:
When c() returns, its frame is popped. When b() returns, its frame is popped. The stack shrinks back to just <module>.
Inspecting Frames with inspect
import inspect
def show_frame_info():
frame = inspect.currentframe()
print(f"Function: {frame.f_code.co_name}")
print(f"File: {frame.f_code.co_filename}")
print(f"Line: {frame.f_lineno}")
print(f"Locals: {frame.f_locals}")
caller = frame.f_back
if caller:
print(f"Called from: {caller.f_code.co_name} line {caller.f_lineno}")
def greet(name):
greeting = f"Hello, {name}"
show_frame_info()
return greeting
greet("Alice")
Output:
Function: show_frame_info
File: /path/to/script.py
Line: 4
Locals: {'frame': <frame object at 0x...>}
Called from: greet line 12
Walking the Full Stack
import inspect
def deep_call():
stack = inspect.stack()
for i, frame_info in enumerate(stack):
print(f" [{i}] {frame_info.function} in {frame_info.filename}:{frame_info.lineno}")
def middle():
deep_call()
def top():
middle()
top()
Output:
[0] deep_call in script.py:3
[1] middle in script.py:9
[2] top in script.py:12
[3] <module> in script.py:15
inspect.stack() returns frames from innermost (current) to outermost (module).
:::note Frame Reference Cycles
Holding a reference to a frame object (frame = inspect.currentframe()) can create reference cycles that delay garbage collection. Always delete frame references when done: del frame.
:::
Reading Python Tracebacks
A traceback shows the call stack at the moment of an exception. It reads from outermost to innermost - the opposite of inspect.stack().
def c():
raise ValueError("something went wrong")
def b():
c()
def a():
b()
a()
Output:
Traceback (most recent call last):
File "script.py", line 10, in <module>
a()
File "script.py", line 8, in a
b()
File "script.py", line 5, in b
c()
File "script.py", line 2, in c
raise ValueError("something went wrong")
ValueError: something went wrong
Reading strategy:
- Start at the bottom - that is where the error occurred (
c()raisedValueError) - Work upward - each line shows who called who
- The top line is the entry point (module level or test runner)
:::tip Read Tracebacks Bottom-Up
The error is always at the bottom. The call chain reads upward. Start at ValueError: ..., then read up to understand how the code got there.
:::
Why Deep Recursion Crashes
Each frame takes memory. Python sets a recursion limit to prevent the OS stack from overflowing:
import sys
print(sys.getrecursionlimit()) # 1000 (default)
def infinite():
return infinite()
infinite()
# RecursionError: maximum recursion depth exceeded
Each frame for a simple function uses roughly 1-2 KB. At 1000 frames deep, that is 1-2 MB of stack space - approaching typical thread stack limits.
Changing the Limit
import sys
sys.setrecursionlimit(5000) # risky - may crash the interpreter
:::danger setrecursionlimit is Risky Increasing the recursion limit may cause a segfault (OS stack overflow) if set too high, or a hard crash with no Python exception. Only use this as a last resort. Prefer converting deep recursion to iteration. :::
Frame Memory Layout in CPython
CPython allocates frames from a free list (frames are reused to avoid repeated malloc/free):
Local variables accessed via LOAD_FAST are stored in a C array inside the frame - this is why local variable access is faster than global (LOAD_FAST is an array index, LOAD_GLOBAL is a dict lookup).
How Generators Suspend Frames
Normal functions: frame is created on call, destroyed on return.
Generators: the frame is suspended between yields and lives on the heap until the generator is exhausted or garbage-collected.
def my_gen():
print("before yield 1")
yield 1
print("before yield 2")
yield 2
print("after last yield")
g = my_gen() # creates generator object (frame not started yet)
next(g) # starts frame, runs until yield 1, suspends frame
# "before yield 1"
next(g) # resumes suspended frame, runs until yield 2
# "before yield 2"
next(g) # StopIteration - frame finished
# "after last yield"
The generator object holds a reference to the suspended frame. The frame retains all local variables across yields. This is why generators are memory-efficient for large sequences - only one frame, iterated on demand.
How Async/Await Suspends Frames
Coroutines (async functions) work similarly - each await can suspend the coroutine's frame on the heap until the awaited operation completes:
import asyncio
async def fetch_data(url):
print(f"Fetching {url}")
await asyncio.sleep(1) # suspend frame, other coroutines run
print(f"Got data from {url}")
return f"data from {url}"
async def main():
results = await asyncio.gather(
fetch_data("https://api.example.com/users"),
fetch_data("https://api.example.com/posts"),
)
print(results)
asyncio.run(main())
The event loop suspends the frame at each await, runs other ready coroutines, and resumes the frame when the I/O completes. Multiple suspended coroutine frames exist simultaneously on the heap - this is how thousands of concurrent connections are handled with a single OS thread.
Practical: Better Error Messages with Frame Inspection
Frame inspection enables writing self-describing error messages:
import inspect
def validate_positive(value, *, _caller_depth=1):
if value <= 0:
caller_frame = inspect.stack()[_caller_depth]
raise ValueError(
f"Expected positive value, got {value!r}. "
f"Called from {caller_frame.function}() "
f"at {caller_frame.filename}:{caller_frame.lineno}"
)
return value
def process_items(count):
validate_positive(count)
process_items(-5)
# ValueError: Expected positive value, got -5.
# Called from process_items() at script.py:14
Interview Questions
Q1: What is a Python stack frame and what does it contain?
Answer: A stack frame (PyFrameObject in CPython) is a data structure created for each active function call. It contains: f_back (pointer to the caller's frame, forming the call chain), f_code (the function's compiled code object), f_locals (local variable namespace), f_globals (global namespace, shared with the module), f_lasti (index of last bytecode executed, for resumption), f_lineno (current source line number), and an embedded value stack for bytecode operands. The chain of f_back pointers from the innermost frame to None (at the module level) constitutes the call stack.
Q2: What is the difference between the call stack and the value stack?
Answer: Python uses two stacks. The call stack is the chain of frame objects - each function call adds a frame, each return removes one. It tracks which functions are active and their local state. The value stack is an operand stack inside each frame, used by the bytecode interpreter to evaluate expressions. For example, a + b pushes a and b onto the value stack, then BINARY_ADD pops both and pushes the result. The value stack is per-frame and reset on each function call. The call stack spans the interpreter's thread stack.
Q3: Why does Python have a recursion limit, and what happens when you exceed it?
Answer: Each function call creates a frame that consumes memory (roughly 1–2 KB). Python's default recursion limit is 1000 (configurable via sys.setrecursionlimit()). The limit exists because each frame uses OS stack space. Without a limit, unbounded recursion would exhaust the OS thread stack and cause a hard crash (segfault) with no recoverable Python exception. When the limit is reached, Python raises RecursionError: maximum recursion depth exceeded - a Python-level exception that can be caught and handled, preventing a hard crash.
Q4: How do generators differ from regular functions in terms of frame lifecycle?
Answer: Regular functions: a frame is created on call, executed to completion, and destroyed on return. Generators: calling a generator function creates a generator object but does NOT start executing the frame. Calling next() starts (or resumes) the frame, which runs until it hits a yield, at which point the frame is suspended - its state (all local variables, current bytecode position) is preserved on the heap inside the generator object. The frame can be resumed later. This is what enables generators to produce values lazily - one at a time - while maintaining state between yields.
Q5: How do you read a Python traceback? Where is the error?
Answer: Python tracebacks print the call stack from outermost (first call, usually module level) to innermost (where the exception occurred). The error is always at the bottom - the last two lines show the exception type and message, and the line just above shows the exact code that raised it. To debug: start at the bottom, find the exception, read the line that caused it. Then work upward: each indented section shows the caller and the line that made the next call. The first line after "Traceback (most recent call last):" is where your code started. Most debugging focuses on the bottom few frames.
Q6: How does async/await relate to stack frames?
Answer: Coroutines (async functions) work similarly to generators at the frame level. Each await expression can suspend the coroutine's frame - preserving all local variables and the current bytecode position. The suspended frame lives on the heap, not the OS stack. The event loop maintains a queue of ready-to-run coroutines; when an awaited operation completes, the event loop resumes the suspended frame. This enables thousands of concurrent coroutines with a single OS thread - each suspended coroutine is just a small heap object with a frozen frame, using far less memory than OS threads.
Practice Challenges
Beginner: Stack Depth Reporter
Write a function current_depth() that returns how many frames deep the current call is (module = 0, one function call deep = 1, etc.).
def a():
print(current_depth()) # 1
def b():
a()
def c():
b()
c() # prints 3 (c → b → a)
Solution
import inspect
def current_depth():
# inspect.stack() returns a list from innermost to outermost
# subtract 1 to not count current_depth itself
return len(inspect.stack()) - 1
def a():
print(current_depth()) # 3 (module → c → b → a = depth 3, minus current_depth)
def b():
a()
def c():
b()
c() # 3
# Alternatively, using frame walking (faster for deep stacks):
def current_depth():
depth = 0
frame = inspect.currentframe()
while frame is not None:
frame = frame.f_back
depth += 1
return depth - 2 # subtract current_depth frame and module frame
Intermediate: Call Logger Decorator
Write a @log_calls decorator that prints a formatted call log with indentation showing call depth, the function name, arguments, and return value.
@log_calls
def factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1)
factorial(4)
Expected output:
→ factorial(4)
→ factorial(3)
→ factorial(2)
→ factorial(1)
← factorial(1) = 1
← factorial(2) = 2
← factorial(3) = 6
← factorial(4) = 24
Solution
import functools
import inspect
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
depth = len(inspect.stack()) - 1
indent = " " * (depth - 1)
args_str = ", ".join(repr(a) for a in args)
if kwargs:
args_str += ", " + ", ".join(f"{k}={v!r}" for k, v in kwargs.items())
print(f"{indent}→ {func.__name__}({args_str})")
result = func(*args, **kwargs)
print(f"{indent}← {func.__name__}({args_str}) = {result!r}")
return result
return wrapper
@log_calls
def factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1)
factorial(4)
Advanced: Exception with Enhanced Traceback
Write a context manager enhanced_traceback() that catches exceptions and re-raises them with a richer message showing the local variables in each frame of the call stack.
def parse_number(s):
with enhanced_traceback():
value = int(s)
return value * 2
parse_number("abc")
# EnhancedError: invalid literal for int() with base 10: 'abc'
# Frame: parse_number - locals: {'s': 'abc', 'value': <not yet assigned>}
# Frame: <module> - locals: {}
Solution
import sys
import inspect
from contextlib import contextmanager
@contextmanager
def enhanced_traceback():
try:
yield
except Exception as e:
tb = sys.exc_info()[2]
frames = []
# Walk the traceback
current_tb = tb
while current_tb is not None:
frame = current_tb.tb_frame
frames.append((
frame.f_code.co_name,
dict(frame.f_locals),
current_tb.tb_lineno,
))
current_tb = current_tb.tb_next
lines = [f"EnhancedError: {e}"]
for name, locals_dict, lineno in frames[1:]: # skip enhanced_traceback's own frame
locals_repr = {k: repr(v) for k, v in locals_dict.items()
if not k.startswith('_')}
lines.append(f" Frame: {name} (line {lineno}) - locals: {locals_repr}")
raise RuntimeError("\n".join(lines)) from e
def compute(x):
result = x / 0 # ZeroDivisionError
def run():
with enhanced_traceback():
compute(42)
run()
# RuntimeError: EnhancedError: division by zero
# Frame: compute (line 2) - locals: {'x': '42'}
# Frame: run (line 6) - locals: {}
Quick Reference
| Concept | Code | Notes |
|---|---|---|
| Current frame | inspect.currentframe() | Returns frame object |
| Caller's frame | frame.f_back | Navigate up the stack |
| Full call stack | inspect.stack() | List from inner to outer |
| Local variables | frame.f_locals | Dict snapshot |
| Function name | frame.f_code.co_name | String |
| Current line | frame.f_lineno | Integer |
| Recursion limit | sys.getrecursionlimit() | Default 1000 |
| Change limit | sys.setrecursionlimit(n) | Use with caution |
| Generator frame | Generator object's frame | Suspended between yields |
| Coroutine frame | Coroutine object's frame | Suspended at await |
Key Takeaways
- Every function call creates a frame - a PyFrameObject holding locals, a pointer to the code object, a pointer to the caller's frame, and the current bytecode position
- The call stack is a chain of frames - linked by
f_backpointers from innermost to the module frame (whosef_backisNone) - Frames enable resume - generators and coroutines suspend their frame on the heap; this is the mechanism behind lazy evaluation and async concurrency
- Read tracebacks from the bottom - the error is at the bottom; work up to understand the call chain
LOAD_FASTis faster thanLOAD_GLOBAL- local variables are C-array indexed; globals require a dict lookupinspect.stack()gives you the full call chain - useful for debugging, logging, and building developer tools- Recursion limit protects against OS stack overflow - increasing it is risky; convert deep recursion to iteration when possible
