Call stack and execution
This guide explains how Python executes function calls, what happens when you call a function, and how the call stack manages execution. Understanding this helps you debug code, understand recursion, and see why certain patterns work the way they do.
The call stack
When Python executes your code, it keeps track of function calls using a call stack. Think of it as a stack of plates: each function call adds a new plate to the top, and when a function returns, that plate is removed.
def greet(name):
return f"Hello, {name}!"
def main():
message = greet("Alice")
print(message)
main()
When Python runs this:
main()is called → a frame is pushed onto the stackgreet("Alice")is called → another frame is pushed on topgreet()returns → its frame is popped offmain()continues → eventually returns and is popped off- Stack is empty → program ends
The call stack ensures Python knows where to return to when a function finishes.
Stack frames
Each function call creates a frame (also called a stack frame or activation record). A frame contains:
- Local variables — names defined in that function
- Parameters — arguments passed to the function
- Return address — where to return when the function finishes
- Code location — which line is currently executing
- References to enclosing scopes — for closures and nested functions
def calculate(x, y):
result = x + y # Local variable
return result # Return address tells Python where to go back
value = calculate(3, 4)
When calculate(3, 4) is called, Python creates a frame with:
- Parameters:
x = 3,y = 4 - Local variables:
result = 7(after the calculation) - Return address: back to where
calculate()was called
How function calls work
Here's what happens step-by-step when you call a function:
def add(a, b):
total = a + b
return total
def main():
x = 5
y = 10
result = add(x, y)
print(result)
main()
Step 0: Program starts
- The global frame (module-level scope) exists on its own at the bottom of the stack
- Stack now has only: global (bottom)
Step 1: main() is called
- A frame for
main()is created and pushed onto the stack above the global frame - Local variables
x = 5andy = 10are stored in themain()frame - Stack now has: global (bottom),
main()(top)
Step 2: add(x, y) is called
- A new frame for
add()is created and pushed on top of themain()frame - Parameters
a = 5andb = 10are stored in the newadd()frame - The return address (line after
add(x, y)) is stored - Stack now has: global (bottom),
main(),add()(top)
Step 3: add() executes
total = a + bcreatestotal = 15in theadd()framereturn totalreturns15to the caller- Stack still has: global (bottom),
main(),add()(top)
Step 4: add() returns
- The
add()frame is popped off the stack - Control returns to
main()at the line afteradd(x, y) result = 15is stored in themain()frame- Stack now has: global (bottom),
main()(top)
Step 5: main() continues and returns
print(result)executesmain()returns, its frame is popped off- Stack now has only: global (bottom)
Step 6: Program ends
- The global frame remains on the stack
- Stack now has only: global (bottom)
- Program execution completes
Nested function calls
When functions call other functions, the stack grows:
def level3():
return "level 3"
def level2():
result = level3()
return f"level 2 -> {result}"
def level1():
result = level2()
return f"level 1 -> {result}"
print(level1())
# Output:
# level 1 -> level 2 -> level 3
The call stack during level3():
┌─────────────┐
│ level3 │ ← Currently executing
├─────────────┤
│ level2 │ ← Waiting
├─────────────┤
│ level1 │ ← Waiting
├─────────────┤
│ module │ ← Waiting
└─────────────┘
Each function waits for the one above it to return before continuing.
Recursion and recursion limits
Recursion is when a function calls itself. Each recursive call adds a new frame to the stack:
def countdown(n):
if n <= 0:
return
print(n)
countdown(n - 1) # Recursive call
countdown(3)
# Output:
# 3
# 2
# 1
The stack during the deepest call (countdown(0)):
┌─────────────┐
│ countdown(0)│ ← Currently executing
├─────────────┤
│ countdown(1)│ ← Waiting
├─────────────┤
│ countdown(2)│ ← Waiting
├─────────────┤
│ countdown(3)│ ← Waiting
└─────────────┘
Recursion limits
Python has a recursion limit to prevent infinite recursion from consuming all memory:
import sys
print(sys.getrecursionlimit()) # Usually 1000 on most systems
def infinite():
return infinite() # Recursive call with no base case
# infinite() # Would eventually raise RecursionError
If you exceed the limit, Python raises a RecursionError:
def recursive(n):
if n == 0:
return 0
return recursive(n - 1)
recursive(10000) # RecursionError: maximum recursion depth exceeded
You can check and modify the limit (though this is rarely needed):
import sys
old_limit = sys.getrecursionlimit()
sys.setrecursionlimit(2000) # Increase limit (not recommended)
print(sys.getrecursionlimit()) # 2000
sys.setrecursionlimit(old_limit) # Restore original
Increasing the recursion limit can cause a stack overflow, crashing Python. It's usually better to rewrite recursive code to be iterative or use tail recursion techniques.
What happens when exceptions are raised
When an exception is raised, Python unwinds the call stack, looking for an exception handler:
def inner():
raise ValueError("Something went wrong!") # Exception raised here
def middle():
inner() # Exception propagates up
def outer():
try:
middle() # Exception propagates up
except ValueError as e:
print(f"Caught: {e}") # Exception caught here
outer()
# Output:
# Caught: Something went wrong!
What happens:
inner()raisesValueError- Python looks for a
try/exceptininner()→ none found - Python pops
inner()frame and checksmiddle()→ none found - Python pops
middle()frame and checksouter()→ foundexcept ValueError - Exception is caught, stack unwinding stops
- Code in the
exceptblock executes
If no handler is found, the exception reaches the top of the stack and Python prints a traceback:
def cause_error():
raise ValueError("Unhandled error!")
cause_error()
# Output:
# Traceback (most recent call last):
# File "<stdin>", line 1, in <module>
# File "<stdin>", line 2, in cause_error
# ValueError: Unhandled error!
Exception propagation
Exceptions propagate up the call stack until handled:
def level3():
raise ValueError("Error at level 3")
def level2():
level3() # Exception propagates through
def level1():
try:
level2() # Exception propagates through
except ValueError:
print("Caught at level 1")
level1()
# Output:
# Caught at level 1
The traceback shows the entire path:
def a():
b()
def b():
c()
def c():
raise ValueError("Error")
a()
# Traceback shows: a() -> b() -> c() -> ValueError
# Traceback (most recent call last):
# File "<stdin>", line 1, in <module>
# File "<stdin>", line 2, in a
# File "<stdin>", line 2, in b
# File "<stdin>", line 2, in c
# ValueError: Error
Tracebacks are useful because they show exactly where the exception originated and how it propagated through the call stack.
Summary
- Call stack — Python uses a stack to track function calls, with frames added when functions are called and removed when they return
- Stack frames — Each function call creates a frame containing local variables, parameters, return address, and scope information
- Function execution — When a function is called, a frame is pushed onto the stack; when it returns, the frame is popped off
- Recursion limits — Python has a recursion limit (usually 1000) to prevent infinite recursion from consuming memory
- No tail-call optimization — Python doesn't optimize tail-recursive calls; use iteration for better performance and stack management
- Exception handling — When exceptions are raised, Python unwinds the stack looking for handlers; if none are found, a traceback is printed
Understanding the call stack and frames helps you:
- Debug code more effectively
- Understand recursion and its limits
- Read tracebacks to find error sources
- Make informed decisions about recursion vs iteration
- Comprehend how Python executes your code
The call stack is a fundamental part of Python's execution model. Every function call, return, and exception involves the stack in some way.