Intuition Why a whole page of examples?
You already met the parts of a function on the parent note : def, parameters, return, docstrings. Knowing the parts is like owning a toolbox. This page is the workshop : we run every kind of job through the machine so that when the exam (or real code) throws a weird case at you, you have already seen it once .
A function can be poked in only so many fundamentally different ways. Below is every "cell" — every distinct behaviour class this topic hides. The examples that follow are each tagged with the cell they cover, so together they leave no gap .
Cell
What makes it different
Example that covers it
A. Basic value return
one input, one clean returned value
Ex 1
B. No return
body runs, but nothing handed back → None
Ex 2
C. print vs return confusion
looks the same on screen, behaves differently
Ex 2
D. Multiple return values
tuple packing / unpacking
Ex 3
E. Default parameter used / overridden
argument omitted vs supplied
Ex 4
F. Keyword args reorder
names beat position
Ex 4
G. Degenerate / edge input
zero, empty, boundary values
Ex 5
H. Early return (branching exit)
return inside if, code after skipped
Ex 6
I. Mutable default trap
the shared-list bug
Ex 7
J. Real-world word problem
translate a story into a function
Ex 8
K. Exam-style twist
trace a tricky call, predict exact output
Ex 9
The three "flavours" of arguments (positional / keyword / default) live in cells E and F ; the branching logic borrows from Control Flow — if statements ; tuple return borrows from Lists and Tuples ; the whole motive is the DRY Principle .
Worked example A basic value return
def square (n):
"""Return n multiplied by itself."""
return n * n
y = square( 6 )
Forecast: before reading on — what is y? Is it printed anywhere?
Python meets square(6) and looks up the function object named square. Why this step? The name is just a label on a machine; the machine must be found before it can run.
Bind the argument to the parameter: n = 6. Why? The value 6 slides into the slot named n.
Run the body: n * n = 6 * 6 = 36. Why? That is the work the machine was built to do.
return 36 — the call expression square(6) becomes the value 36, and the function exits. Why? return does two jobs at once: hands the value back and stops.
y = 36. Nothing is printed — y just holds the value. Why? Assignment captures whatever the call evaluated to.
Verify: 6 × 6 = 36 . A square of a whole number is that number's area as a grid — a 6 × 6 grid holds 36 cells. ✓
Worked example The disappearing answer
def show_square (n):
print (n * n) # displays, but does NOT return
z = show_square( 6 )
print (z)
Forecast: Two things print. Guess both lines exactly.
show_square(6) runs: print(36) puts 36 on screen. Why this step? print is a side effect — it talks to the human.
The function reaches its end with no return. So the call quietly returns None. Why? Every function must hand something back; when you don't say what, Python hands back the "nothing" value None.
z = None. Why? The call evaluated to None, and assignment copies that in.
print(z) puts None on screen. Why? We asked it to display whatever z holds.
Verify: Output is two lines — 36 then None. Compare with Ex 1: there y was a usable 36; here z is useless for further maths. That is the whole print-vs-return lesson in one experiment. ✓
Common mistake "I saw the number, so I got the number"
Wrong feeling: the 36 on screen is the returned value. It looks identical to Ex 1's result.
Fix: the screen shows what print displays ; the caller receives what return hands back . Here nothing was handed back → None.
Worked example Quotient and remainder together
def divmod2 (a, b):
"""Return (quotient, remainder) of a divided by b."""
return a // b, a % b
q, r = divmod2( 17 , 5 )
Forecast: What are q and r? And what type does divmod2 really return?
Bind a = 17, b = 5. Why this step? Fill both slots by position.
a // b = 17 // 5 = 3 — integer (floor) division, "how many whole 5's fit in 17". Why? // throws away the fractional part, giving whole groups.
a % b = 17 % 5 = 2 — the remainder, "what's left over". Why? % is the leftover after those whole groups.
return 3, 2 — the comma builds a tuple (3, 2). Why? A function can hand back only one object, so Python bundles the two into one tuple (see Lists and Tuples ).
q, r = (3, 2) — tuple unpacking splits it: q = 3, r = 2. Why? The left side has two names, the right side has two slots — they line up.
Verify: rebuild the original: q × b + r = 3 × 5 + 2 = 17 = a . ✓ The identity a = ( a // b ) ⋅ b + ( a % b ) must always hold.
Worked example One function, three call styles
def power (base, exp = 2 ):
"""Return base raised to exp; squares by default."""
return base ** exp
a = power( 5 ) # (i)
b = power( 2 , 3 ) # (ii)
c = power( exp = 3 , base = 2 ) # (iii)
Forecast: Predict a, b, c — and note which call reorders the inputs.
(i) power(5): only base supplied, so base = 5. exp was omitted → it falls back to its default 2. Result 5 ** 2 = 25. Why this step? A default parameter fills its own slot when the caller stays silent.
(ii) power(2, 3): matched by position → base = 2, exp = 3. Result 2 ** 3 = 8. Why? With no names given, Python lines arguments up left-to-right.
(iii) power(exp=3, base=2): matched by name , so order on the line is irrelevant → base = 2, exp = 3. Result 2 ** 3 = 8. Why? Keyword arguments say which slot explicitly, overriding position.
Verify: 5 2 = 25 , 2 3 = 8 , 2 3 = 8 . Calls (ii) and (iii) name the same filling in a different order and correctly give the same 8. ✓
Worked example What happens at the edges?
def average (numbers):
"""Return the mean of a list of numbers."""
return sum (numbers) / len (numbers)
average([ 4 , 6 , 8 ]) # (i) normal
average([ 7 ]) # (ii) single element
average([]) # (iii) empty <-- danger
Forecast: Which of these three breaks , and why?
(i) sum([4,6,8]) = 18, len = 3, so 18 / 3 = 6.0. Why this step? The mean is total divided by count.
(ii) sum([7]) = 7, len = 1, so 7 / 1 = 7.0. Why? One item's average is itself — a healthy boundary case.
(iii) sum([]) = 0, len = 0, so 0 / 0 → ZeroDivisionError , a crash. Why? Dividing by zero has no answer; an empty list has no count to divide by.
A robust version guards the degenerate case (borrowing Control Flow — if statements ):
def average (numbers):
"""Return the mean, or None for an empty list."""
if len (numbers) == 0 :
return None # early exit for the degenerate input
return sum (numbers) / len (numbers)
Verify: normal = 6.0, single = 7.0, empty now returns None instead of crashing. ✓ Every input class is now handled.
Worked example Code after
return never runs
def classify (n):
"""Return 'zero', 'negative', or 'positive'."""
if n == 0 :
return "zero"
if n < 0 :
return "negative"
return "positive"
classify( 0 ) # (i)
classify( - 4 ) # (ii)
classify( 9 ) # (iii)
Forecast: For classify(0), do the later if n < 0 and final return ever execute?
(i) n = 0: first if is true → return "zero". The function exits immediately ; the n < 0 check and the last return are never reached. Why this step? return stops the machine on the spot — no fall-through.
(ii) n = -4: n == 0 false, skip. n < 0 true → return "negative". Why? We only reach the second test because the first didn't fire.
(iii) n = 9: both ifs false, so we fall to the final return "positive". Why? It is the catch-all when nothing above returned.
Verify: outputs "zero", "negative", "positive" — every sign of n (zero, negative, positive) has exactly one path, and no path runs code after its own return. ✓
Worked example The list that remembers
def add_item (x, bag = []): # DANGER: shared default list
bag.append(x)
return bag
print (add_item( 1 )) # (i)
print (add_item( 2 )) # (ii)
Forecast: You expect [1] then [2]. Is that what happens?
The default [] is created once , at the moment def runs — not on each call. Why this step? The default value is stored with the function object , so all calls that omit bag share the same list.
(i) add_item(1): bag is that shared list, append 1 → [1], returned and printed. Why? First mutation of the shared list.
(ii) add_item(2): bag is still the same list (now [1]), append 2 → [1, 2]. Why? It never got reset — the default was not re-created.
Verify: output is [1] then [1, 2], not [1] then [2]. The bug is real. The None-sentinel fix from the parent note gives a fresh list each call:
def add_item (x, bag = None ):
if bag is None :
bag = [] # fresh list every call
bag.append(x)
return bag
Now the two calls give [1] then [2] — independent. ✓
Worked example Splitting a restaurant bill
A group had a meal costing total rupees. They want to add a tip percentage and split the sum evenly among people. Write a function returning the amount each person pays, and test it on a ₹800 bill, 10% tip, 4 people.
Forecast: Roughly, should each person pay a bit more or less than ₹200? (200 is the no-tip split.)
Translate the story into inputs and one output. Why this step? A function's job is exactly "inputs → one answer", so naming them first clarifies the machine.
def share_per_person (total, people, tip_percent = 10 ):
"""Return each person's share of total after adding tip_percent."""
grand_total = total * ( 1 + tip_percent / 100 )
return grand_total / people
Add the tip: 800 * (1 + 10/100) = 800 * 1.1 = 880. Why? A 10% tip multiplies the bill by 1.1.
Split among people: 880 / 4 = 220. Why? Even split = grand total divided by head count.
tip_percent has a sensible default of 10, so share_per_person(800, 4) also works. Why? Most groups tip ~10%; a default saves typing (cell E again).
Verify: each pays ₹220, which is more than the ₹200 no-tip split by exactly the tip share 4 800 × 0.10 = 4 80 = 20 . Units: rupees per person. ✓
Worked example Predict the precise output
def f (a, b = 1 ):
"""Twist: a default AND an early return."""
if a > b:
return a - b
return b - a
print (f( 5 )) # (i)
print (f( 2 , 5 )) # (ii)
print (f( 3 , 3 )) # (iii)
Forecast: Write down all three printed numbers before checking. Watch the default and the equal case.
(i) f(5): b omitted → default b = 1. Is a > b? 5 > 1 true → return 5 - 1 = 4. Why this step? Default fills b, then the branch chooses the subtraction order.
(ii) f(2, 5): a = 2, b = 5. 2 > 5 false → skip, fall to return 5 - 2 = 3. Why? When a is smaller, the first return never fires, so we hit the second (early-return logic from cell H).
(iii) f(3, 3): a = 3, b = 3. 3 > 3 false (equal is not greater) → return 3 - 3 = 0. Why? The > boundary excludes equality, so the equal case goes down the second path.
Verify: outputs 4, 3, 0. Notice this function actually computes ∣ a − b ∣ , the absolute difference — check: ∣5 − 1∣ = 4 , ∣2 − 5∣ = 3 , ∣3 − 3∣ = 0 . ✓ Every ordering (greater, smaller, equal) is covered.
Recall Cover the answers — did every cell stick?
Basic return square(6)? ::: 36
z = show_square(6) then print(z) prints? ::: 36 then None
divmod2(17, 5) gives q, r =? ::: q = 3, r = 2
power(exp=3, base=2)? ::: 8
average([]) in the unguarded version does what? ::: raises ZeroDivisionError
add_item(1) then add_item(2) with the buggy shared default print? ::: [1] then [1, 2]
share_per_person(800, 4) (10% default tip)? ::: 220.0
f(3, 3) where f returns a-b if a>b else b-a? ::: 0
Mnemonic The matrix in one breath
"Value, Void, Vary, Verge, Vanish" → returns a V alue, or V oid (None), V ary the args (defaults/keywords), the V erge cases (zero/empty), and V anishing early-returns.
Parent topic — the anatomy — this page is its "every scenario" workshop.
Control Flow — if statements — powers the branching returns in Ex 5, 6, 9.
Lists and Tuples — tuple packing/unpacking behind Ex 3's multiple return.
Variables and Scope — why the mutable-default list in Ex 7 outlives a single call.
Lambda and Higher-order Functions — the same input→output idea, written in one line.
Recursion — a function calling itself, built on the very return traced here.
DRY Principle — the reason every example above was worth wrapping in a function.