Module 4: Functions and Program Structure
Return Values, Scope, and Side Effects
Why returning a value and printing one are not interchangeable, which names a function can see, and what makes a function easy or impossible to test.
Lesson 14 of 46 in the recommended order · About 25 min (estimate)
On this page
Outcome
By the end of this lesson you can say what a function gives back to its caller, name every value a function can see and change, and rewrite a printing function into a returning one so it becomes testable.
Why it matters
"It printed the right thing" and "it produced the right value" feel identical when you are watching a terminal, and they are completely different to everything downstream. A printed answer is gone. It cannot be stored, compared, re-formatted, written to a file, or checked by a test.
Almost every function in the capstone has to be tested, and Module 5 is where those tests get written. A function that prints instead of returning cannot be tested without awkward machinery, so this lesson is what makes the next one possible.
Concept
return value ends the function immediately and hands value back to the caller. A function with no return, or one that falls off the end, returns None. Any code after a return on the same path never runs.
print writes text to the screen and returns None. It is an output operation, not a way of producing an answer. result = print("hello") binds result to None, which is a real mistake people make once.
A function can return several values by returning a tuple, and the caller can unpack it in one line:
verdict, reason = judge(record)
Beyond two or three values, return a dictionary instead: names beat positions once a reader has to remember an order.
Scope decides which names are visible. Names created inside a function are local: they exist while the call runs and vanish when it returns. A function can read names defined at the top level of the file, which is how it sees constants. It cannot rebind them; an assignment inside a function creates a local name that shadows the outer one, and the outer value is untouched.
Reading a value in place is different from rebinding a name. A function that receives a list can call append on it and the caller sees the change, because both names refer to the same object. That is a side effect, and it is legal, useful, and the single most common source of surprise when reviewing someone else's function.
A pure function reads only its arguments and returns a value, changing nothing else. Pure functions are trivial to test: give inputs, compare the output. Prefer them for anything that computes a decision, and confine side effects, printing, writing files, mutating shared state, to a thin layer that a reader can see at a glance.
Read the code
MINIMUM_VALUE = 100000
def score(record):
"""Return (points, notes). Pure: reads its argument and the module
constant, changes nothing."""
points = 0
notes = "baseline"
if record["estimated_value"] >= MINIMUM_VALUE:
points += 2
notes = "meets minimum"
if record.get("set_aside") == "Total Small Business":
points += 3
notes = notes + "; category matches"
return points, notes
def record_and_report(record, log):
"""Impure: appends to the caller's list, then returns nothing useful."""
points, notes = score(record)
log.append(record["notice_id"])
print(f"{record['notice_id']}: {points} ({notes})")
entries = []
record_and_report(
{"notice_id": "GSA-2026-0731", "estimated_value": 310000, "set_aside": "Total Small Business"},
entries,
)
print(entries)
print(record_and_report({"notice_id": "A-2", "estimated_value": 10}, entries))
Two functions with deliberately different characters. score reads its argument and one module-level constant, and returns a pair. Nothing outside it changes, so a test can call it with a record and compare the result.
record_and_report does three things: computes, mutates the caller's list, and prints. It has no return, so it hands back None. Its usefulness lives entirely in its side effects, which is why the last print line looks so odd.
Predict the output
Predict all four lines of output.
Check your prediction
GSA-2026-0731: 5 (meets minimum; category matches)
['GSA-2026-0731']
A-2: 0 (baseline)
None
The second line shows the side effect: entries was created empty at the top level and now holds an identifier, because append changed the very list that was passed in.
The fourth line is None, printed by the outer print because record_and_report returns nothing. The A-2 line above it came from the inner print during the call. Reading those two lines in the right order is the whole lesson in miniature: printing happens when the function runs, returning is what the caller receives.
Follow the return path; separate the side effects
Caller
Passes record data and the entries list to record_and_report.
record_and_report
Local record and log parameters. Calls score; unpacks its tuple into local points and notes.
score
Reads local record and module MINIMUM_VALUE = 100000. Returns its local points and notes as a tuple.
Separate side effects inside record_and_report
- log.append → shared entries list
- log and entries refer to the same list. append changes that list; it does not create a returned copy.
- print → screen
- Writes the report line. print itself returns None, which is ignored. This is separate from record_and_report falling off its end and returning None.
1. GSA-2026-0731 call
score returns- (5, 'meets minimum; category matches')
entries after append- ['GSA-2026-0731']
After this call, the caller prints entries: only the first identifier is in the list.
Next step in this same run
2. A-2 call
score returns- (0, 'baseline')
entries after append- ['GSA-2026-0731', 'A-2']
The inner print writes the A-2 report, then the surrounding caller print writes None. This final two-item list is not printed.
Modify the code
Inside score, add the line MINIMUM_VALUE = 0 immediately before return points, notes. Predict what happens.
What changes, and why
The function raises UnboundLocalError on the comparison line, before it can return anything.
Assigning to MINIMUM_VALUE anywhere in the body makes it a local name for the whole function, including the lines above the assignment. So the comparison now reads a local name that has not been given a value yet.
Two things follow. First, the module-level constant is never in danger: a function cannot rebind an outer name by accident, which is a safety feature rather than an inconvenience. Second, the error appears on the line that reads the name, not on the line that assigns it, which is why this error message is confusing the first time. When you see UnboundLocalError, look for an assignment to that same name lower down.
Debug the bug
An assistant was asked for a function that returns the highest-value record in a batch. It produced this and said it returns the record for A-3.
def highest(batch):
best = None
for record in batch:
if best is None or record["estimated_value"] > best["estimated_value"]:
best = record
return best
batch = [
{"notice_id": "A-1", "estimated_value": 50000},
{"notice_id": "A-2", "estimated_value": 90000},
{"notice_id": "A-3", "estimated_value": 400000},
]
print(highest(batch)["notice_id"])
What's actually wrong
It prints A-1.
The return best is indented inside the loop, so it runs at the end of the very first pass. The function returns after examining one record and never sees the other two. The loop is written correctly; it is simply never allowed to finish.
Move the return out one level, so it sits after the loop rather than inside it:
def highest(batch):
best = None
for record in batch:
if best is None or record["estimated_value"] > best["estimated_value"]:
best = record
return best
This is wrong output, not an error, and it is one of the few bugs that indentation alone can cause in Python while leaving the code entirely valid. A misplaced return inside a loop is worth checking for whenever a function that should scan a collection appears to consider only its first item.
Worth noting separately: highest([]) returns None, so highest(batch)["notice_id"] would raise TypeError on an empty batch. The function's contract should say what it does with no records, and its docstring should say it too.
Try it yourself
The function below prints its answer. Change it to return the verdict and the reason, then print them at the call site in the exact shape the comment asks for.
Loading this exercise…
Practical challenge (optional)
Optional: write a second function report(record) that calls your judge and prints the formatted line, so that judge stays pure and every side effect lives in report. Then write one sentence explaining which of the two you could test without capturing output. Separating a decision from its presentation is the structural move that Module 12's read-only interface depends on.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
- What does a function return when it has no
returnstatement? - What is bound by
result = print("hi")? - Can a function rebind a name defined at the top level of the file just by assigning to it?
- Why is a pure function easier to test than one that prints?
Answers
None.None.printperforms output and returns nothing; the text on screen is a side effect, not the value.- No. The assignment makes the name local for the whole function body, and the outer name is unaffected. Reading it without assigning still works.
- A test can call it and compare the returned value directly. Checking printed output means capturing the output stream, which is more machinery and a weaker check.
Sign in to track your progress on this exercise.
Summary and next step
return hands a value back and ends the call, print only writes to the screen, a missing return yields None, assigning to an outer name makes it local for the whole body, and pure functions are the ones you can test. Next: taking the shortlist script apart into named, contracted functions without changing a single behaviour.