Lesson 4: Functions
🎧 Listen to this lesson
By Lesson 3 your programs already repeat themselves: the same grade chain copied three times, the same cleaning steps in two places. Copied code is a liability — fix a bug in one copy and the others keep the bug. A function is a named, reusable chunk of code: define it once with def, call it whenever you need it. This lesson covers parameters and return values, default arguments, scope, docstrings, and the mistakes that make functions return None when you least expect it.
Defining and calling functions
A function definition names the machine; a call runs it. Values go in as arguments, land in the parameters, and a result comes out with return:
def area_circle(radius):
pi = 3.14159
return pi * radius ** 2
print(area_circle(5))
print(area_circle(10))
Output:
78.53975
314.159
Two calls, two different arguments, one definition. The return statement is the function's output hatch: execution stops there and the value travels back to the caller. A function with no return — or one that only prints — hands back the special value None. Printing inside a function is showing a value on a screen; returning is giving the value to the rest of the program so it can be stored, added, compared or passed onward. Confusing the two is the most common function bug in beginner code, and the Common mistakes section shows the crash it causes.
Defaults, docstrings and one job per function
A parameter with a default value becomes optional — def greet(name="friend") can be called as greet() or greet("Ada"). A docstring — a triple-quoted string directly under the def line — documents what the function promises:
def calculate_tip(bill, percent=15):
"""Return the tip amount for a bill."""
return bill * percent / 100
print(calculate_tip(80)) # uses the default 15%
print(calculate_tip(80, 20)) # overrides it with 20%
Output: 12.0 then 16.0. Two rules keep functions healthy. One function, one job: if you describe a function with the word "and" ("it reads the file and checks the data and prints the report"), it is three functions wearing a trench coat — split it. Good names are documentation: calculate_tax tells the reader the story; ct makes them reverse-engineer it.
Scope: where variables live
Variables created inside a function are local — they exist only during that call and are invisible outside:
def set_score():
score = 50 # this score lives only inside the function
score = 10 # a completely separate score
set_score()
print(score)
Output: 10. The function built its own score, set it to 50, and threw it away on exit; the outer score never changed. This isolation is a feature, not an annoyance: a 500-line program may use the name total in a dozen functions without them interfering. Values cross the boundary in exactly two controlled ways — in through parameters, out through return. (Reading an outer variable from inside a function is allowed, which is how the guessing game in Lesson 2 would read a shared secret; writing one requires the global keyword and is usually a design smell.)
Worked example 1 — a tip calculator built from functions
Real programs layer functions: small ones reused by bigger ones.
def calculate_tip(bill, percent=15):
"""Return the tip amount for a bill."""
return bill * percent / 100
def split_bill(bill, percent, people):
"""Return how much each person pays, tip included."""
total = bill + calculate_tip(bill, percent)
return total / people
print(f"Each pays: ${split_bill(100, 18, 4):.2f}")
print(f"Each pays: ${split_bill(64, 15, 2):.2f}")
Output:
Each pays: $29.50
Each pays: $36.80
Trace the first call: split_bill(100, 18, 4) calls calculate_tip(100, 18), which returns 18.0; the total is 118.0; divided by 4 people gives 29.5, and the :.2f in the f-string prints it as $29.50. split_bill never computes a tip itself — it delegates to calculate_tip, so if the tip rule ever changes (a service charge, a cap), you edit exactly one place. That is composition: programs built from functions calling functions, the way essays are built from sentences.
Worked example 2 — a reusable grade toolkit
Functions earn their keep when several programs share them. This small toolkit converts any score to a letter, checks passes, and summarises a list:
def grade_letter(score):
"""Return the letter grade (A-F) for a numeric score."""
if score >= 90:
return "A"
elif score >= 80:
return "B"
elif score >= 70:
return "C"
elif score >= 60:
return "D"
else:
return "F"
def class_summary(scores):
"""Print each score with its grade, then the pass count."""
passed = 0
for s in scores:
letter = grade_letter(s)
print(s, "->", letter)
if letter != "F":
passed += 1
print(f"Passed: {passed}/{len(scores)}")
class_summary([95, 82, 71, 58])
Output:
95 -> A
82 -> B
71 -> C
58 -> F
Passed: 3/4
Study the control flow inside grade_letter: each return exits immediately, so the first matching band wins and later bands are never tested — the same order-is-logic rule as the if-chain in Lesson 2, compressed. And notice the += shorthand in passed += 1, which means passed = passed + 1. The function class_summary reuses grade_letter rather than re-deriving grades — change the grade boundaries in one function and every summary in every program updates.
return. If a function prints but never returns, callers receive None — decide which one you actually need.Common mistakes
- Printing instead of returning.
def double(x): print(x * 2)followed bytotal = double(5) + 1crashes withTypeError: unsupported operand type(s) for +: 'NoneType' and 'int', becausedoublereturned None. The error names the real culprit if you read it. - The mutable default argument.
def add_item(item, box=[]):creates the list once, when the function is defined — every call that omitsboxshares the same list, so items mysteriously accumulate between calls. The safe pattern isdef add_item(item, box=None):withif box is None: box = []inside. Defaults should be immutable values: numbers, strings, None. - Forgetting the return entirely. A function that computes an answer into a local variable but never returns it gives callers None. If a result seems to vanish, check the last lines for a missing
return. - Calling before defining. Python reads top to bottom: a call above the
defraises NameError. Definitions go at the top of the file; the code that starts the program goes at the bottom. - Argument order mix-ups.
split_bill(4, 18, 100)runs happily with the wrong meaning — Python matches positionally and cannot know you swapped them. Long parameter lists invite this; keep signatures short and names clear.
Real-World Challenge
The task. Build a small temperature-conversion library: functions c_to_f(celsius), f_to_c(fahrenheit), and a convert(value, unit) dispatcher that takes a unit of "C" or "F" and returns the converted value in the other scale, rounded to one decimal. Finish with a short demo that converts a table of readings in both directions.
Worked solution.
def c_to_f(celsius):
"""Convert Celsius to Fahrenheit."""
return celsius * 9 / 5 + 32
def f_to_c(fahrenheit):
"""Convert Fahrenheit to Celsius."""
return (fahrenheit - 32) * 5 / 9
def convert(value, unit):
"""Convert value from unit ("C" or "F") to the other scale."""
if unit == "C":
return round(c_to_f(value), 1)
elif unit == "F":
return round(f_to_c(value), 1)
else:
return None
readings = [(0, "C"), (100, "C"), (32, "F"), (98.6, "F"), (-40, "C")]
for value, unit in readings:
other = "F" if unit == "C" else "C"
print(f"{value} {unit} = {convert(value, unit)} {other}")
Output:
0 C = 32.0 F
100 C = 212.0 F
32 F = 0.0 C
98.6 F = 37.0 C
-40 C = -40.0 F
How it works. The two converters hold the formulas — c * 9 / 5 + 32 and its exact inverse — and nothing else; the dispatcher adds policy (which scale, rounding, what to do with a bad unit) without duplicating maths. The demo loop unpacks each (value, unit) pair from the list, works out the target scale with a conditional expression, and prints the conversion. Verify the landmarks by hand: water freezes at 0 C / 32 F, boils at 100 C / 212 F, body temperature 98.6 F is 37.0 C, and -40 is the famous crossover where both scales agree — a test case professionals genuinely use, because a converter that fails at -40 has its formulas tangled.
Extension. Add a third scale: write c_to_k(celsius) (Kelvin = Celsius + 273.15) and teach convert to accept "K" as a target via a second parameter, convert(value, unit, target). Then convert 25 C to Kelvin (298.15) and 0 K to Fahrenheit (-459.67) and check both.
Going further — returning several values, and accepting any number of arguments
A function returns exactly one object — but that object can be a tuple, which callers unpack into several variables in one gesture. Statistics, coordinates and min-max pairs all travel this way:
def min_max(numbers):
return min(numbers), max(numbers)
low, high = min_max([4, 9, 1, 7])
print(low, high)
print(min_max([4, 9, 1, 7]))
Output:
1 9
(1, 9)
Unpacked, the values are convenient; left packed, the tuple prints with its parentheses as a reminder of what the function really handed back. The mirror feature serves inputs: *args collects any number of positional arguments into a tuple inside the function, which is how print itself accepts unlimited values:
def total(*numbers):
return sum(numbers)
print(total(1, 2, 3), total(10, 20))
Output: 6 30. Use it when the *count* of inputs is genuinely variable — totals, averages, joining paths — and prefer ordinary parameters when the count is fixed, because named parameters document themselves and catch mistakes at the call site. Both features reappear throughout the standard library's signatures; recognising *args in documentation (Lesson 12) is the difference between reading a function's contract and guessing at it.
Practice corner — the mutable default trap
One Python behaviour surprises everyone exactly once: a default argument value is created once, when the function is defined — so a mutable default like a list is *shared* across every call that relies on it:
def add_item(item, box=[]):
box.append(item)
return box
print(add_item("a"))
print(add_item("b"))
Output:
['a']
['a', 'b']
The second call did not start fresh — the box remembered. The professional fix is a None default with construction inside (if box is None: box = []), so each call that needs a box builds its own. Immutable defaults (numbers, strings, None) are perfectly safe; it is mutability plus sharing that bites — the same aliasing lesson as Lesson 10's class attribute, wearing a different costume.
🎬 Watch & Learn
Prefer video? These searches surface top tutorials on this lesson's topic:
- Python functions — full beginner tutorial (freeCodeCamp style) — pick a recent, well-reviewed video
- Corey Schafer: Python functions — pick a recent, well-reviewed video
- CS50: functions and decomposition in Python — pick a recent, well-reviewed video
💪 Practice Zone
Try these yourself — hints are included, answers are not. Run your code in any Python environment.
- Area function — Write
area(width, height)that returns the area of a rectangle. Test it witharea(5, 3)— expect 15.Hint
def area(width, height):thenreturn width * height. - Friendly greeting — Write
greet(name="friend")that printsHello, <name>!. Call it with no argument and with your name.Hint
A default parameter looks likedef greet(name="friend"):. - Max of three — Write
max_of_three(a, b, c)that returns the largest — without using the built-inmax().Hint
Compare two numbers at a time and keep the winner:biggest = a if a > b else b, then compare withc. - Temperature converter — Write
c_to_f(celsius)returning Fahrenheit. Test:c_to_f(0)→ 32.0,c_to_f(100)→ 212.0.Hint
Formula:f = celsius * 9/5 + 32.
🌍 Real World
Professional code is built from functions: payment companies like Stripe wrap charge_card(amount) in a function so thousands of programs reuse one tested implementation. Whenever a programmer says "don't repeat yourself," they mean "put it in a function" — exactly what you practiced here.
Unit 2: Data, Loops, and Files
🎧 Listen to this unit