Python Functions: 10 Code-Along Examples

Learn Python functions by running them. Ten copy-and-run examples covering return versus print, keyword arguments, tuple returns, defaults, *args and **kwargs, scope, closures, lambdas, map and filter, and error handling.

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 total
result = calculate_total(4.50, 3) # the returned value is CAPTURED
print(result) # 13.5
print(result * 2) # 27.0: we can keep computing with it
def show_total(price, quantity):
print(price * quantity) # displays, returns nothing
captured = 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 ORDER
print(format_label("Keyboard", 79.99, "£"))
# keyword: matched by NAME, order no longer matters
print(format_label(price=79.99, currency="£", product="Keyboard"))
# mixing is fine, but positional arguments must come first
print(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 automatically
catalogue = [79.99, 24.50, 299.00, 12.99]
result = min_max_price(catalogue)
print(result) # (12.99, 299.0) one tuple
print(result[0]) # 12.99 indexable like any tuple
cheapest, dearest = min_max_price(catalogue) # unpacked into two names
print(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% applies
print(apply_discount(100, 0.25)) # 75.0: override positionally
print(apply_discount(100, rate=0.5)) # 50.0: or by keyword
# defaults must come AFTER required parameters in the def line
def 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)) # 60
print(summarise(5)) # 5
def 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 variable
def report():
message = f"status is {status}" # READING a global: fine
return message
print(report()) # status is idle
# print(message) # NameError: message was local, it's gone
def update_status(new_value):
global status # declare intent to REBIND the global
status = new_value
update_status("running")
print(status) # running
def better_update(current, new_value): # the cleaner alternative
return new_value if new_value else current
status = better_update(status, "done") # pass in, return out
print(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 result
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(10), triple(10)) # 20 30: each closure kept its own factor
def make_counter():
count = 0
def click():
nonlocal count # rebind the ENCLOSING variable, not a global
count += 1
return count
return click
counter = 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 reduce
prices = [100, 250, 40, 610, 95]
# map: transform every item
with_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 test
big_tickets = list(filter(lambda p: p > 100, prices))
print(big_tickets) # [250, 610]
# reduce: collapse to a single value
total = reduce(lambda a, b: a + b, prices)
print(total) # 1095
cube = 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 None
print(safe_divide(10, 2)) # 5.0
print(safe_divide(10, 0)) # warning printed, then None
def 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.0
try:
apply_discount(100, 3) # a 300% discount is a bug, not a bargain
except 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_order
price_uk = make_pricer() # 20% VAT captured in the closure
orders = [(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 pair
print(f"total £{total}, of which VAT £{vat}") # total £162.0, of which VAT £27.0
try:
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 mapfilter, 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.

View Comments (2)

Leave a Reply

Prev Next

Subscribe to My Newsletter

Subscribe to my email newsletter to get the latest posts delivered right to your email. Pure inspiration, zero spam.

Discover more from Discuss Data Science, Machine Learning and Analytics

Subscribe now to keep reading and get access to the full archive.

Continue reading