The functions guide covers the machinery that turns repeated code into named, reusable tools: def and return, parameters and arguments, scope, closures, the flexible *args and **kwargs, lambdas, and error handling. This workbook runs each idea in isolation and then combines them into one realistic utility. Two distinctions carry the whole topic, and they arrive early: return versus print in Example 1, and parameters versus arguments in Example 2. Get those two and everything else is detail.
1. Anatomy of a function: def, docstring, return
A function has four parts: the def line naming it and its parameters, a docstring saying what it does, a body, and a returnhanding back the result. The critical distinction is that return gives a value back to the caller, while print merely displays it.
def calculate_total(price, quantity): """Return the total cost for a quantity of items.""" total = price * quantity return totalresult = calculate_total(4.50, 3) # the returned value is CAPTUREDprint(result) # 13.5print(result * 2) # 27.0: we can keep computing with itdef show_total(price, quantity): print(price * quantity) # displays, returns nothingcaptured = show_total(4.50, 3) # prints 13.5...print(captured) # ...but captured is None
calculate_total hands its answer back, so the caller can store it, reuse it, and pass it onward. show_total only displays the number; the function itself returns None, so the value is gone the moment it scrolls past. A function that prints is a dead end; a function that returns is a building block, and that is why almost every function you write should return.
2. Parameters, arguments, and keywords
Parameters are the placeholders in the def line; arguments are the actual values supplied at the call. Positional arguments match by order, and keyword arguments match by name, which lets you reorder and self-document the call.
def format_label(product, price, currency): return f"{product}: {currency}{price:.2f}"# positional: matched by ORDERprint(format_label("Keyboard", 79.99, "£"))# keyword: matched by NAME, order no longer mattersprint(format_label(price=79.99, currency="£", product="Keyboard"))# mixing is fine, but positional arguments must come firstprint(format_label("Keyboard", currency="£", price=79.99))
All three calls produce Keyboard: £79.99. The keyword form earns its keep on functions with many parameters, where format_label(price=79.99, ...) documents itself while a bare format_label("Keyboard", 79.99, "£") forces the reader to remember which slot is which. The one rule: once you switch to keywords, no positional arguments may follow.
3. Returning multiple values with tuples
A function can return several values at once by packing them into a tuple, which the caller unpacks back into separate names in one line.
def min_max_price(prices): """Return the cheapest and dearest price in one call.""" return min(prices), max(prices) # packed into a tuple automaticallycatalogue = [79.99, 24.50, 299.00, 12.99]result = min_max_price(catalogue)print(result) # (12.99, 299.0) one tupleprint(result[0]) # 12.99 indexable like any tuplecheapest, dearest = min_max_price(catalogue) # unpacked into two namesprint(f"from £{cheapest} to £{dearest}")
The bare comma in the return line does the packing, and the two-names-on-the-left assignment does the unpacking. Tuples are ordered and immutable, so the pair travels safely as one object. One value in, two meaningful values out, with no classes or dictionaries needed: this is the everyday Python idiom for functions that naturally produce more than one answer.
4. Default arguments
A default value in the def line makes a parameter optional. Callers who say nothing get the default; callers who care can override it.
def apply_discount(price, rate=0.10): """Discount a price; 10 percent unless told otherwise.""" return round(price * (1 - rate), 2)print(apply_discount(100)) # 90.0: the default 10% appliesprint(apply_discount(100, 0.25)) # 75.0: override positionallyprint(apply_discount(100, rate=0.5)) # 50.0: or by keyword# defaults must come AFTER required parameters in the def linedef greet_user(name, greeting="Hello"): return f"{greeting}, {name}!"print(greet_user("Ava")) # Hello, Ava!print(greet_user("Ava", greeting="Wotcha")) # Wotcha, Ava!
Defaults keep the common case short while leaving the door open, which is exactly how libraries like pandas manage functions with twenty options you almost never touch. The ordering rule is enforced by Python itself: required parameters first, defaulted ones after, or the def line will not even compile.
5. *args and **kwargs: accepting anything
Sometimes a function cannot know how many arguments it will receive. *args collects extra positional arguments into a tuple, **kwargs collects extra keyword arguments into a dictionary, and the full parameter order is: required, defaults, *args, **kwargs.
def summarise(*args): """Total any number of values.""" print(type(args), args) # a plain tuple return sum(args)print(summarise(10, 20, 30)) # 60print(summarise(5)) # 5def log_event(event, **kwargs): """Log an event plus any extra details supplied.""" print(f"EVENT: {event}") for key, value in kwargs.items(): # a plain dict print(f" {key} = {value}")log_event("purchase", customer="Ava", amount=79.99, item="Keyboard")def process_inputs(required, optional=0, *args, **kwargs): return f"{required=} {optional=} {args=} {kwargs=}"print(process_inputs(1, 2, 3, 4, mode="fast"))# required=1 optional=2 args=(3, 4) kwargs={'mode': 'fast'}
Inside the function there is no magic: args is an ordinary tuple and kwargs an ordinary dictionary, loopable and indexable like any other. The process_inputs line shows all four kinds coexisting in their mandatory order. This pattern is why print()accepts any number of things and why libraries can pass options through layers without naming every one.
6. Scope: local, global, and the global keyword
Names created inside a function are local: they exist only while the function runs. Reading a global from inside a function works automatically, but rebinding one requires the global keyword, and needing it often is a design smell.
status = "idle" # a global variabledef report(): message = f"status is {status}" # READING a global: fine return messageprint(report()) # status is idle# print(message) # NameError: message was local, it's gonedef update_status(new_value): global status # declare intent to REBIND the global status = new_valueupdate_status("running")print(status) # runningdef better_update(current, new_value): # the cleaner alternative return new_value if new_value else currentstatus = better_update(status, "done") # pass in, return outprint(status) # done
The local message evaporates when report finishes, which is the point of scope: functions cannot trample each other’s names. global works, but every function that silently rewrites shared state becomes harder to test and reason about; the better_updatepattern, take the value in as a parameter and return the replacement, keeps the data flow visible and is almost always the better design.
7. Nested functions, closures, and nonlocal
A function defined inside another can serve as a private helper, and, more powerfully, the inner function remembers the outer function’s variables even after the outer one has finished. That memory is a closure.
def make_multiplier(factor): """Build and return a function that multiplies by factor.""" def multiply(value): return value * factor # 'factor' is captured from the outer scope return multiply # return the FUNCTION itself, not a resultdouble = make_multiplier(2)triple = make_multiplier(3)print(double(10), triple(10)) # 20 30: each closure kept its own factordef make_counter(): count = 0 def click(): nonlocal count # rebind the ENCLOSING variable, not a global count += 1 return count return clickcounter = make_counter()print(counter(), counter(), counter()) # 1 2 3: state that persists between calls
make_multiplier finished executing long before double(10) ran, yet factor survived inside the returned function: that is the closure. nonlocal is to enclosing scopes what global is to module scope, and the counter shows why it exists, giving a function private, persistent state with no class in sight. Closures are also the mechanism underneath decorators, the natural next step from here.
8. Lambdas with map, filter, and reduce
A lambda is an anonymous one-line function for the moments a full def is ceremony. Its natural habitat is as the argument to map, which transforms every item, filter, which keeps items passing a test, and reduce, which collapses a sequence to one value.
from functools import reduceprices = [100, 250, 40, 610, 95]# map: transform every itemwith_vat = list(map(lambda p: round(p * 1.2, 2), prices))print(with_vat) # [120.0, 300.0, 48.0, 732.0, 114.0]# filter: keep items passing a testbig_tickets = list(filter(lambda p: p > 100, prices))print(big_tickets) # [250, 610]# reduce: collapse to a single valuetotal = reduce(lambda a, b: a + b, prices)print(total) # 1095cube = lambda x: x ** 3 # a lambda can be named, but at that point...print(cube(4)) # ...a def is clearer; keep lambdas throwaway
Each lambda is a rule stated inline: add VAT, keep the expensive ones, accumulate a sum. map and filter return lazy iterators, hence the list() wrappers to see the results. The closing note is the guide’s own advice: the moment a lambda earns a name or a second line, promote it to a def, because lambdas are for logic too small to be worth naming.
9. Error handling: try, except, and raise
Functions meet bad input. try/except recovers gracefully from failures you expect, and raise creates failures deliberately, rejecting invalid input at the door instead of letting it corrupt a result later.
def safe_divide(a, b): """Divide, but survive a zero denominator.""" try: return a / b except ZeroDivisionError: print("warning: division by zero, returning None") return Noneprint(safe_divide(10, 2)) # 5.0print(safe_divide(10, 0)) # warning printed, then Nonedef apply_discount(price, rate): """Fail fast on a nonsense discount rate.""" if not 0 <= rate <= 1: raise ValueError(f"rate must be between 0 and 1, got {rate}") return round(price * (1 - rate), 2)print(apply_discount(100, 0.2)) # 80.0try: apply_discount(100, 3) # a 300% discount is a bug, not a bargainexcept ValueError as error: print(f"rejected: {error}")
safe_divide shows the recovery side: the specific ZeroDivisionError is caught, everything else still fails loudly, as it should. apply_discount shows the enforcement side: raise stops a nonsense rate immediately, with a message naming the actual bad value. Failing fast at the boundary is a kindness to your future self, because an exception at the call site is far easier to debug than a silently wrong price three functions later.
10. Putting it together: an order pipeline
The finale combines the workbook: defaults, **kwargs, a closure, tuple returns, validation with raise, and a lambda, all in one small order-processing utility.
def make_pricer(vat_rate=0.20): """Closure: build a pricing function locked to one VAT rate.""" def price_order(unit_price, quantity=1, **options): if unit_price <= 0 or quantity <= 0: raise ValueError("price and quantity must be positive") subtotal = unit_price * quantity discount = options.get("discount", 0) # optional extras via kwargs shipping = options.get("shipping", 4.99) subtotal *= (1 - discount) total = round(subtotal * (1 + vat_rate) + shipping, 2) return total, round(subtotal * vat_rate, 2) # (total, vat) as a tuple return price_orderprice_uk = make_pricer() # 20% VAT captured in the closureorders = [(25.00, 2), (9.99, 1), (120.00, 1)]totals = list(map(lambda o: price_uk(*o), orders))print(totals) # [(64.99, 10.0), (16.98, 2.0), (148.99, 24.0)]total, vat = price_uk(50, 3, discount=0.1, shipping=0) # unpack the pairprint(f"total £{total}, of which VAT £{vat}") # total £162.0, of which VAT £27.0try: price_uk(-5)except ValueError as error: print(f"rejected: {error}")
Trace the pieces: the closure locks the VAT rate in once, quantity=1 is a default, **options absorbs optional extras without cluttering the signature, the guard clause raises on nonsense input, the return is an unpackable tuple, and a lambda maps the pricer across a batch of orders. No single piece is advanced; the skill is composing them, and this thirty-line utility is the shape that real production helpers actually take.
Work through these and you have the whole article in practice: function anatomy and the return-versus-print distinction, positional and keyword arguments, tuple returns, defaults, *args and **kwargs, scope, closures with nonlocal, lambdas with map, filter, and reduce, and error handling with try/except and raise. The habit that compounds most is the first one: make functions return values rather than print them, because a function that returns can be tested, reused, and composed into pipelines like Example 10, and one that prints can only be watched.
See you soon.
[…] Python Functions: 10 Code-Along Examples […]
[…] Python Functions: 10 Code-Along Examples […]