When a function is called, execution leaves the current location, jumps into the function body, runs every line there, and then returns — bringing a value back with it. Tracing functions means following that jump precisely: recording what values go in as arguments, what happens inside, and what value comes back out. The key insight is that variables inside a function are separate from variables outside it. They do not interfere with each other.
This article is part of the Code Reading, Tracing & Prediction section. It assumes you are comfortable with trace tables, tracing loops, and tracing conditionals. Those techniques all appear inside functions — this article adds the layer on top: understanding what happens at the moment of a function call and return.
| Level | What it covers | When to read it |
|---|---|---|
| Basic idea | What happens when a function is called; parameters, local variables, and return values in a trace table | Right now. |
| At a deeper level | Local scope; print vs return; multiple calls to the same function; functions that call other functions | Once you have traced a single function call on your own. |
| At the deepest level | The call stack; what scope means at the machine level; why local variables disappear | When the basic and deeper levels feel solid and you want to understand what Python is actually doing underneath. |
1 What Happens When a Function Is Called
A function definition does nothing on its own. The lines inside the def block are not executed when Python reads them — they are stored, waiting. Execution only enters a function when the function is called.
When a call happens, three things occur in sequence:
- Arguments are passed in. The values provided in the call are assigned to the function's parameters.
- The function body runs. Every line inside the function executes, using its own local variables.
- The function returns. If there is a
returnstatement, its value is sent back to the place that made the call. Execution resumes from there.
Here is the simplest possible example:
def double(n):
result = n * 2
return result
answer = double(5)
print(answer)When double(5) is called, the value 5 is passed in as n. The function computes 5 * 2 = 10, stores it in result, and returns 10. That 10 is then stored in answer. The program prints 10.
When tracing a function call, the trace table gains a new section for the function body. The clearest way to show this is to draw a horizontal dividing line in the table when execution enters the function, label the section with the function name and the argument values, and draw another dividing line when execution returns.
| Line | answer | n | result | Output |
|---|---|---|---|---|
| Main program | ||||
answer = double(5) — call | ||||
| Inside double(n=5) | ||||
n = 5 (parameter) | 5 | |||
result = n * 2 | 10 | |||
return result | Returns 10. Exit function. | |||
| Back in main program | ||||
answer = 10 (return value stored) | 10 | |||
print(answer) | 10 | |||
The column headers include both the main-program variables (answer) and the function's local variables (n, result). Local variables only have values during the section labelled "Inside double" — before and after, those cells are empty, because those variables do not exist in the main program.
The return statement does two things simultaneously: it determines what value leaves the function, and it ends the function immediately. No lines after a return statement will run in that function call. In your trace table, the return row spans all columns and notes both the returned value and the fact that execution exits the function.
When Python calls a function, it creates a new frame — a self-contained block of memory that holds the function's local variables, its parameters, and a pointer back to the location that made the call. This frame is pushed onto the call stack, a structure that keeps track of all active function calls.
When the function returns, its frame is popped off the stack and discarded. Every local variable in that frame ceases to exist. Execution resumes in the frame below — the one that made the call — at the line immediately following the call expression.
The section dividers in the trace table directly model this frame structure: each section represents one frame on the call stack. Understanding frames is what makes the next article's topics — functions calling other functions, and why local variables are isolated — feel natural rather than mysterious.
2 Parameters and Arguments
A parameter is the name in the function definition — the placeholder. An argument is the actual value passed in when the function is called. When the call happens, each argument is assigned to its corresponding parameter. After that, the parameter behaves exactly like any other variable inside the function.
def greet(name): # name is the parameter
print("Hello,", name)
greet("Alex") # "Alex" is the argumentWhen greet("Alex") is called, the assignment name = "Alex" happens automatically. Inside the function, name contains "Alex".
When a function has multiple parameters, the arguments are matched by position — the first argument goes to the first parameter, the second to the second, and so on. The names of variables used as arguments outside the function have no effect on what the parameters are called inside.
def add(x, y):
return x + y
a = 3
b = 7
total = add(a, b)
print(total)| Line | a | b | total | x | y | Output |
|---|---|---|---|---|---|---|
| Main program | ||||||
a = 3 | 3 | |||||
b = 7 | 7 | |||||
total = add(a, b) — call | ||||||
| Inside add(x=3, y=7) | ||||||
x = 3 (parameter) | 3 | |||||
y = 7 (parameter) | 7 | |||||
return x + y | 3 + 7 = 10. Returns 10. Exit function. | |||||
| Back in main program | ||||||
total = 10 (return value stored) | 10 | |||||
print(total) | 10 | |||||
The main program has variables a, b, and total. The function has variables x and y. The fact that the programmer named the main-program variables a and b while the function uses x and y does not matter. The values are copied across at the moment of the call: x gets the value of a (which is 3), and y gets the value of b (which is 7).
When you write the section header for a function call, write the parameter names and the values they received — for example, Inside add(x=3, y=7) — not the names of the variables that were passed in. The function does not know or care what those outer variables were called. It only knows the values it received.
Python passes arguments by object reference. When you call add(a, b), Python does not copy the values of a and b — it copies references to the objects those variables point to. For immutable types like integers and strings, this distinction does not matter: since the object cannot be changed, the function cannot affect the caller's variables. For mutable types like lists, it matters significantly — a function can modify a list in place and those changes will be visible in the calling code. This topic is covered in the Tracing with Lists and Dictionaries article.
3 Local Variables and Scope
Variables created inside a function are local to that function. They exist only while the function is running. When the function returns, they are gone. The main program cannot see them. The function cannot see the main program's variables either — unless they are passed in as arguments.
This isolation is called scope. Each function has its own scope — its own private set of variables.
This means two functions can both use a variable called result without any conflict. Each one has its own result, and they never interact.
Here is a program that demonstrates scope:
def square(n):
result = n * n
return result
def cube(n):
result = n * n * n
return result
a = square(4)
b = cube(3)
print(a, b)Both functions use a local variable called result. They do not interfere with each other. Each function's result exists only during that function's execution.
| Line | a | b | n | result | Output |
|---|---|---|---|---|---|
| Main program | |||||
a = square(4) — call | |||||
| Inside square(n=4) | |||||
n = 4 (parameter) | 4 | ||||
result = n * n | 16 | ||||
return result | Returns 16. n and result cease to exist. Exit function. | ||||
| Back in main program | |||||
a = 16 (return value stored) | 16 | ||||
b = cube(3) — call | |||||
| Inside cube(n=3) | |||||
n = 3 (parameter) | 3 | ||||
result = n * n * n | 27 | ||||
return result | Returns 27. n and result cease to exist. Exit function. | ||||
| Back in main program | |||||
b = 27 (return value stored) | 27 | ||||
print(a, b) | 16 27 | ||||
The result column contains 16 when square runs and 27 when cube runs. Between calls — and after the final return — those cells are empty because the variable does not exist in the main program's scope.
In the return row for each function, explicitly note that the local variables cease to exist: "Returns 16. n and result cease to exist." This is not just a formatting choice — writing it forces you to think about what the calling code can and cannot see after the function returns. The only thing the calling code receives is the return value. Everything else is gone.
Python's scoping rules follow the LEGB rule: when Python looks up a variable name, it searches four scopes in order — Local (the current function), Enclosing (any outer functions, in nested function definitions), Global (the module level), and Built-in (Python's built-in names like print and len).
For Grade 9 work, the relevant scopes are Local and Global. A function can read a global variable without declaring it — but if it tries to assign to a variable with the same name as a global, Python creates a new local variable instead of modifying the global. This behaviour is a common source of subtle bugs: a function appears to update a global variable but actually creates a shadow local variable, leaving the global unchanged. The fix is to pass the value in as a parameter and return the updated value — which is the correct pattern regardless.
4 print vs return
print() and return are not the same thing. This is one of the most common points of confusion for new programmers, and it causes bugs that can be very hard to find without tracing.
print()displays something on the screen. It does not give a value back to the caller. The function still returnsNonewhen it finishes.returnsends a value back to the caller. Nothing is displayed. The caller can store that value in a variable and use it.
A function that prints instead of returning looks like it worked — you can see the output. But the value is lost. The caller receives None, not the computed result.
Here are two functions that look similar but behave differently:
# Version A: returns the value
def double_return(n):
return n * 2
# Version B: prints the value
def double_print(n):
print(n * 2)
result_a = double_return(5)
result_b = double_print(5)
print(result_a)
print(result_b)| Line | result_a | result_b | n | Output |
|---|---|---|---|---|
| Main program | ||||
result_a = double_return(5) — call | ||||
| Inside double_return(n=5) | ||||
n = 5 (parameter) | 5 | |||
return n * 2 | 5 * 2 = 10. Returns 10. No output. Exit function. | |||
| Back in main program | ||||
result_a = 10 | 10 | |||
result_b = double_print(5) — call | ||||
| Inside double_print(n=5) | ||||
n = 5 (parameter) | 5 | |||
print(n * 2) | 10 | |||
| end of function — no return statement | Returns None implicitly. Exit function. | |||
| Back in main program | ||||
result_b = None | None | |||
print(result_a) | 10 | |||
print(result_b) | None | |||
The full output of this program is:
10 10 None
The first 10 comes from the print() inside double_print. The second 10 comes from print(result_a). The None comes from print(result_b) — because double_print returned None, not 10. The value was displayed but not returned.
If a function has no return statement — or has a return with no value — Python returns None automatically when the function ends. In the trace table, always note this explicitly in the exit row: "Returns None implicitly." If a variable in the calling code is assigned the result of such a function, that variable will contain None, which will cause a TypeError the moment you try to do arithmetic or any other operation with it.
None is Python's way of representing "no value." It is its own type — NoneType — and is distinct from zero, an empty string, or False. When a function is called purely for its side effects (displaying output, writing to a file, modifying a list in place) rather than to compute a value, returning None is correct and intentional. Python's built-in print() function itself returns None — its purpose is the side effect of writing to the screen, not producing a value.
The design principle is: a function should either compute and return a value, or perform an action and return nothing — not both. Mixing the two (a function that both modifies state and returns a computed value) makes code harder to reason about. In Grade 9, this is a heuristic rather than a rule, but it is a good habit to develop.
5 Multiple Calls to the Same Function
A function can be called as many times as you like. Each call is completely independent. The function starts fresh every time — it does not remember what happened in previous calls. Local variables are created anew on each call and destroyed when that call returns.
Here is a function called three times with different arguments:
def classify(score):
if score >= 60:
return "pass"
else:
return "fail"
print(classify(75))
print(classify(42))
print(classify(60))| Line | score | Output |
|---|---|---|
| Call 1: classify(75) | ||
score = 75 (parameter) | 75 | |
if score >= 60: | 75 >= 60 → True. Take if branch. | |
return "pass" | Returns "pass". Exit function. | |
print(classify(75)) | pass | |
| Call 2: classify(42) | ||
score = 42 (parameter) | 42 | |
if score >= 60: | 42 >= 60 → False. Take else branch. | |
return "fail" | Returns "fail". Exit function. | |
print(classify(42)) | fail | |
| Call 3: classify(60) | ||
score = 60 (parameter) | 60 | |
if score >= 60: | 60 >= 60 → True. Take if branch. | |
return "pass" | Returns "pass". Exit function. | |
print(classify(60)) | pass | |
Each call gets its own section. The score column is reset fresh at the start of each section. The third call confirms a boundary: a score of exactly 60 is a pass, because the condition is >= (greater than or equal to), not >.
The independence of function calls is guaranteed by the frame model described in Section 1. Each call creates a new frame with its own fresh copy of all local variables and parameters. Previous calls' frames have been popped off the stack and discarded before the new call begins. There is no shared state between calls — unless the function deliberately reads or writes to a global variable or a mutable object passed in from outside, which is why those patterns require care.
6 Functions That Call Other Functions
A function can call another function. When it does, execution jumps into the called function, completes it, returns the value, and then continues inside the first function. The trace table gains a nested section inside the outer function's section.
The principle is the same as before — each call creates a new section, local variables are isolated — but now the sections are nested inside each other.
def square(n):
return n * n
def sum_of_squares(a, b):
return square(a) + square(b)
print(sum_of_squares(3, 4))| Line | a | b | n | Output |
|---|---|---|---|---|
| Main program | ||||
print(sum_of_squares(3, 4)) — call | ||||
| Inside sum_of_squares(a=3, b=4) | ||||
a = 3 (parameter) | 3 | |||
b = 4 (parameter) | 4 | |||
return square(a) + square(b) — calls square(3) first | ||||
| Inside square(n=3) | ||||
n = 3 (parameter) | 3 | |||
return n * n | 3 * 3 = 9. Returns 9. Exit square. | |||
| Back inside sum_of_squares — now calls square(4) | ||||
| Inside square(n=4) | ||||
n = 4 (parameter) | 4 | |||
return n * n | 4 * 4 = 16. Returns 16. Exit square. | |||
| Back inside sum_of_squares | ||||
return 9 + 16 | = 25. Returns 25. Exit sum_of_squares. | |||
| Back in main program | ||||
print(25) | 25 | |||
The return expression square(a) + square(b) requires two function calls to evaluate. Python evaluates left to right: it calls square(3) first, gets 9, then calls square(4), gets 16, then computes 9 + 16 = 25 and returns that from sum_of_squares.
When functions call other functions, the trace order does not follow the lines of code top to bottom. It follows execution — wherever the program jumps, your trace follows. When you see a function call on the right-hand side of an assignment or inside a return statement, pause: that call runs to completion before the assignment or return can proceed.
The sequence of nested sections in the trace table directly reflects the state of the call stack at each moment. When sum_of_squares calls square(3), the call stack has two frames: sum_of_squares at the bottom, square on top. When square returns, its frame is popped, leaving only sum_of_squares. When sum_of_squares calls square(4), a new frame is pushed. When that returns, it is popped again. When sum_of_squares itself returns, only the main program frame remains.
When a Python program crashes with a traceback, the traceback is a printout of the call stack at the moment of the crash — listed from the outermost call at the top to the innermost (the crash site) at the bottom. Understanding the call stack is what makes tracebacks readable. Each line in a traceback corresponds to one frame — one section in a trace table.
- In your own words, describe the three things that happen in sequence when a function is called. What triggers each one?
Trace this program. Draw section dividers between the main program and the function body. Show all local variables in the function section and note when they cease to exist.
def multiply(a, b): product = a * b return product x = 6 y = 7 answer = multiply(x, y) print(answer)Here is a function that has a bug. Trace it with
n = 4to find the bug, then explain what the fix should be and why.def triple(n): result = n * 3 print(result) output = triple(4) print(output + 1)Trace this program with all three calls. Use a separate section for each function call. Note the boundary value carefully.
def is_even(n): if n % 2 == 0: return True else: return False print(is_even(4)) print(is_even(7)) print(is_even(0))- Explain in two or three sentences why two functions can both use a local variable called
totalwithout interfering with each other. What guarantees that isolation? Trace this program. Pay careful attention to the order in which the inner function calls happen.
def add_one(n): return n + 1 def double(n): return n * 2 result = add_one(double(3)) print(result)Which function runs first —
add_oneordouble? Why?