Runtime Model (Call Stack)¶
The call stack defines how function execution is organized at runtime. Each function call creates a new frame, allowing functions to isolate execution while still composing into larger programs.
When functions call other functions, Python must remember where execution should return after each function finishes. This is handled by the call stack.
Mental Model
The call stack is a stack of sticky notes. Each time a function is called, Python writes down "where to return" on a new note and pushes it on top. When the function finishes, Python pops the top note and resumes at the recorded location. This is how Python keeps track of deeply nested function calls.
Nested Function Calls¶
Functions can call other functions.
```python def g(): print("inside g")
def f(): print("inside f") g() print("back in f")
f() ```
Output
text
inside f
inside g
back in f
Execution order:
f()starts runningf()callsg()g()runs and finishes- execution returns to
f() f()continues and finishes
How the Call Stack Works¶
The call stack keeps track of which functions are currently running.
The program itself runs inside a main frame at the bottom of the call stack.
The stack follows a last-in, first-out (LIFO) rule: the most recent function call is always on top of the stack. The function at the top of the stack is the one currently running.
Program start:
text
+--------+
| main |
+--------+
When main calls f():
text
+--------+
| f |
| main |
+--------+
When f() calls g():
text
+--------+
| g |
| f |
| main |
+--------+
g() is now running. f() is paused and waiting for g() to finish.
When g() returns:
text
+--------+
| f |
| main |
+--------+
Execution returns to f().
When f() returns:
text
+--------+
| main |
+--------+
When the program ends:
text
(empty)
Stack Frames¶
Each function call creates a stack frame that stores information needed while the function runs.
A stack frame stores:
- local variables
- the current execution position
- where to return after the function completes
Each function call creates its own separate stack frame. This means that local variables in one function are completely isolated from local variables in another, even if they share the same name.
```python def f(): x = 10 g() print("f sees x =", x)
def g(): x = 99 print("g sees x =", x)
f() ```
Output
text
g sees x = 99
f sees x = 10
Both f and g use a variable named x, but each function has its own stack frame with its own x.
When g sets x = 99, it does not affect the x inside f.
Tracebacks and the Call Stack¶
When an error occurs, Python prints a traceback.
A traceback lists the sequence of calls from the program entry point to the location where the error occurred.
```python def g(): x = 1 / 0
def f(): g()
f() ```
Output
text
Traceback (most recent call last):
File "example.py", line 7, in <module>
f()
File "example.py", line 5, in f
g()
File "example.py", line 2, in g
x = 1 / 0
ZeroDivisionError: division by zero
This traceback shows the call stack at the moment of the error.
Tip
Reading a traceback is essentially reading the call stack from the point where the error occurred.
Key Ideas¶
Python tracks function calls using the call stack, which follows a last-in, first-out rule. Each function call creates a stack frame that holds its own local variables, execution position, and return address. Because each call gets its own frame, local variables are fully isolated between functions. Tracebacks are snapshots of the call stack at the moment an error occurs — learning to read them is one of the most practical debugging skills in Python.
Understanding the call stack becomes especially important when learning recursion.
Putting It Together¶
Functions provide a complete abstraction over program execution:
- parameters define inputs
- return values define outputs
- control flow defines the success path
- exceptions define the failure path
- the call stack defines how execution is organized
Functions allow programs to scale by separating concerns: each function handles a small part of execution, while the call stack composes them into larger behavior. Understanding functions means understanding how programs are structured, executed, and controlled.
Notebook Examples¶
```python def f(): x = 10 print(x)
f() ```
```python x = 10
def f(): print(x)
f() ```
```python x = 10
def f(): x = 7 print(x)
f() ```
```python x = 10
def f(): x = 7 def g(): print(x) g()
f() ```
```python x = 10
def f(): x = 7 def g(): x = 5 print(x) g()
f() ```
```python x = 10
def f(): #x = 7 def g(): #x = 5 print(x) g()
f() ```
```python
LEGB - local ---> enclosing ---> global ---> built-in¶
x = 10 # <--- global
def f(): #x = 7 <--- enclosing def g(): #x = 5 <--- local print(x) g()
f() ```
python
print(__name__)
print(__file__)
python
print(__doc__)
```python def add(a, b): """Returns a + b.""" return a + b
print(add.doc) # 'Returns a + b.' print(help(add)) ```
```python def add(a, b): """Returns a + b.""" return a + b
print(add.doc) # 'Returns a + b.' help(add) ```
```python x = 10
def f(): print(x)
f() ```
```python x = 10
def f(): x = x + 1 # UnboundLocalError # when "x = ", python think x is a local variable print(x)
f() ```
```python x = 10
def f(): global x x = x + 1 # UnboundLocalError # when "x = ", python think x is a local variable print(x)
f() print(x) ```
```python x = 10
def f(): def g(): global x x = x + 1 # UnboundLocalError # when "x = ", python think x is a local variable print(x) g()
f() print(x) ```
```python x = 10
def f(): x = 11 def g(): nonlocal x x = x + 1 # UnboundLocalError # when "x = ", python think x is a local variable print(x) # <--- 12 g() print(x) # <--- 12
f() print(x) # <--- 10 ```
```python x = 10
def f(): #x = 11 def g(): nonlocal x x = x + 1 # UnboundLocalError # when "x = ", python think x is a local variable print(x) # <--- 12 g() print(x) # <--- 12
f() print(x) # <--- 10 ```
```python def f(n: int) -> int: """n: int >= 0""" # base case if n <= 1: return n
# recursive case
return f(n-1) + f(n-2)
for n in range(10): print( f(n) ) ```
```python num_fun_frames = 0
def f(n: int) -> int: """n: int >= 0""" global num_fun_frames num_fun_frames += 1 #print(num_fun_frames, end="\t")
# base case
if n <= 1:
return n
# recursive case
return f(n-1) + f(n-2)
f(100) print(num_fun_frames) ```
```python
memoization (top down)¶
memo = {0 : 0, 1 : 1}
def f(n: int) -> int: """n: int >= 0""" global memo if n not in memo: # n is key of dictionary memo[n] = f(n-1) + f(n-2) return memo[n]
print( f(0) ) # no memo writing print( f(1) ) # no memo writing print( f(2) ) # memo[2] print( f(100) ) ```
```python
bottom up¶
def f(n: int) -> int: """n: int >= 0""" # base case if n <= 1: return n
# recursive case
a_n_minus_2 = 0
a_n_minus_1 = 1
for i in range(2, n+1):
a_n = a_n_minus_1 + a_n_minus_2
a_n_minus_2, a_n_minus_1 = a_n_minus_1, a_n
return a_n
print( f(0) ) # no memo writing print( f(1) ) # no memo writing print( f(2) ) # memo[2] print( f(3) ) print( f(4) ) print( f(5) ) print( f(6) ) ```
```python def fib(n: int) -> int: """Return the nth Fibonacci number, n >= 0.""" if n < 0: raise ValueError("n must be >= 0") return _fib_pair(n)[0]
def _fib_pair(n: int) -> tuple[int, int]: """ Returns (F(n), F(n+1)). """ if n == 0: return (0, 1)
a, b = _fib_pair(n // 2) # a = F(k), b = F(k+1)
c = a * (2 * b - a) # F(2k)
d = a * a + b * b # F(2k+1)
if n % 2 == 0:
return (c, d)
else:
return (d, c + d)
print(fib(0)) print(fib(1)) print(fib(2)) print(fib(10)) print(fib(100)) print(fib(1000)) ```
Exercises¶
Exercise 1. Each function call creates a new stack frame with its own local variables. Predict the output:
```python def f(x): x = x + 1 print(f"f: x = {x}") return x
x = 10 result = f(x) print(f"main: x = {x}") print(f"main: result = {result}") ```
Why does the x in main remain 10 even though f changes x to 11? How does the call stack explain this?
Solution to Exercise 1
Output:
text
f: x = 11
main: x = 10
main: result = 11
The x in main and the x in f are different variables in different stack frames. When f(x) is called, a new stack frame is created for f with its own local x, initialized to the value 10. The line x = x + 1 inside f modifies f's local x to 11, but this has no effect on main's x.
The call stack explains this: main's frame has x = 10, and f's frame has its own x = 11. When f returns, its frame is destroyed, and execution returns to main's frame where x is still 10. The returned value 11 is stored in result.
Exercise 2. When a recursive function calls itself, each call adds a new frame to the stack. Predict what happens:
```python def countdown(n): print(n) countdown(n - 1)
countdown(5) ```
Why does this eventually crash with RecursionError? What is Python's default recursion limit, and why does it exist? How would you fix this function?
Solution to Exercise 2
The function prints 5, 4, 3, 2, 1, 0, -1, -2, ... and eventually crashes with:
text
RecursionError: maximum recursion depth exceeded
Each call to countdown(n - 1) adds a new frame to the call stack. Since there is no base case to stop the recursion, the stack grows indefinitely. Python's default recursion limit is 1000 frames (checked via sys.getrecursionlimit()). This limit exists to prevent stack overflow, which would crash the entire Python process.
Fixed version:
python
def countdown(n):
if n < 0: # base case
return
print(n)
countdown(n - 1)
The base case if n < 0: return stops the recursion, ensuring the stack eventually unwinds.
Exercise 3. A programmer sees this traceback and must identify the bug:
text
Traceback (most recent call last):
File "app.py", line 15, in <module>
result = process(data)
File "app.py", line 10, in process
return transform(item)
File "app.py", line 5, in transform
return int(value)
ValueError: invalid literal for int() with base 10: 'hello'
Read the traceback and answer: which function raised the error? Which function called it? What was the original call that started the chain? Why does Python print the traceback in order from outermost to innermost call?
Solution to Exercise 3
- Which function raised the error?
transform-- the error occurred at line 5, insidetransform, whenint(value)was called with the string'hello'. - Which function called it?
process-- line 10 inprocesscalledtransform(item). - What was the original call? Line 15 in
<module>(the main script) calledprocess(data).
The call chain is: <module> -> process -> transform -> int() (error).
Python prints the traceback from outermost to innermost (most recent call last) because the most useful information -- where the error actually occurred -- is at the bottom. This way, you see the immediate cause first when reading from the bottom up, and can trace the full call chain by reading upward.