Python Functions: A Practical Guide

Functions enable code reusability by allowing logic to be defined once and called multiple times. This guide covers function anatomy, parameters, return values, scope, nested functions, default arguments, and error handling methods in Python, including practical examples. It also discusses lambdas, variable arguments, and common built-in functions.

Functions are the building block of reusable code. Instead of writing the same logic in five different places, you write it once, give it a name, and call it whenever you need it. This guide covers everything from the basic anatomy through closures, variable arguments, lambdas, and error handling.

The Anatomy of a Function

def calculate_total(price, quantity):
"""Returns the total cost for a given price and quantity."""
result = price * quantity
return result

def tells Python you are defining a function. The names in parentheses are parameters, placeholders for the values the caller will provide. The indented block is the function body. The docstring at the top, wrapped in triple quotes, explains what the function does and is accessible later via calculate_total.__doc__return is how the function hands its result back. To actually use it: calculate_total(9.99, 3) runs the function with price = 9.99 and quantity = 3.

print vs return

This distinction trips up almost every beginner:

def greet_user():
print("Welcome back!")
def greet_user():
return "Welcome back!"
message = greet_user()
print(message)

print writes text to the terminal so a human can see it, but the function itself returns nothing useful, technically Nonereturnhands the value back to the caller so other code can store it, pass it along, or compute with it. A function that only prints cannot have its result captured or used downstream. Use return when the value will be needed later. Use print only when you specifically want to display something to the terminal right now.

Parameters and Arguments

def format_label(text):
return text.upper() + " ---"
format_label("sale")
def combine_labels(text1, text2):
return text1.upper() + " | " + text2.upper()
combine_labels("new arrivals", "clearance")

Parameters are the placeholder names in the function definition. Arguments are the actual values passed in when calling. With multiple parameters, Python matches them by position: the first argument goes to the first parameter, the second to the second, and so on.

Returning Multiple Values

A function can only return one thing, but a tuple counts as one thing:

def min_max_price(prices):
return (min(prices), max(prices))
lowest, highest = min_max_price([12.99, 4.50, 8.75, 22.00])

By packaging multiple values into a tuple, you effectively return several at once. Tuple unpacking on the receiving end distributes the elements across separate variables automatically. The number of variables on the left must match the number of items in the tuple, otherwise Python raises an error.

Tuples themselves are straightforward:

dimensions = (1920, 1080, 3)
width, height, channels = dimensions
dimensions[0]

An ordered sequence of values, indexable from zero, and immutable once created. The immutability is the key distinction from lists: once a tuple is created, its contents cannot be changed, which makes them appropriate for things like fixed measurements or function return values where accidental modification would be a problem.

Scope

Variables in Python have a scope that determines where they can be seen. Local variables are created inside a function and exist only for the duration of that function call. Global variables are created outside any function and are accessible everywhere.

active_status = "online"
def update_status():
global active_status
active_status = "away"
update_status()
print(active_status)

By default, writing active_status = "..." inside a function creates a new local variable, even if a global with the same name exists. The global keyword overrides this: it tells Python that the next assignment refers to the global variable, not a new local one. Without it, update_status() would modify only a local copy, leaving the global unchanged. Modifying global state from inside functions is generally a sign of fragile design, so use global sparingly.

Nested Functions and Closures

Functions can be defined inside other functions:

def process_inputs(val1, val2, val3):
def normalise(value):
return round(value / 100, 2)
return (normalise(val1), normalise(val2), normalise(val3))

The inner function normalise only exists during the execution of process_inputs. It is a private helper, invisible to the outside world. This pattern is useful when a small repeated operation is only relevant in one place.

Closures extend this idea. An inner function can remember variables from the outer function even after the outer function has finished:

def make_multiplier(factor):
def apply(value):
return value * factor
return apply
triple = make_multiplier(3)
triple(7)

make_multiplier(3) creates apply with factor = 3 baked into it, then returns apply itself, not the result of calling it. Notice there are no parentheses after apply on the return line. When you later call triple(7), Python still has access to factor even though make_multiplier has finished running. This is a function factory: make_multiplier(10) would produce a ten-times multiplier from the same definition.

When an inner function needs to modify a variable from the enclosing function rather than just read it, use nonlocal:

def build_message(base):
text = base * 2
def add_punctuation():
nonlocal text
text = text + "!"
add_punctuation()
print(text)

Without nonlocal, the assignment inside add_punctuation would create a new local variable that shadows the outer one. Three keywords for three scopes: nothing for the local scope, nonlocal for the enclosing function, and global for the module level.

Default Arguments

Default arguments make parameters optional by providing fallback values:

def format_output(text, repeat=1, capitalise=False):
output = text * repeat
if capitalise:
return output.upper() + "!"
return output + "!"
format_output("hello")
format_output("hello", repeat=3)
format_output("hello", repeat=3, capitalise=True)

When the caller does not provide a value, Python uses the default. When they do, their value takes over. Using the parameter name explicitly, repeat=3, makes long calls more readable and lets you skip earlier default parameters. One requirement: parameters with defaults must come after parameters without defaults in the function signature, because Python needs to know which positional arguments are required first.

Variable Positional Arguments with *args

When you do not know in advance how many arguments will be passed:

def summarise(*values):
total = 0
for v in values:
total += v
return total
summarise(10)
summarise(10, 20, 30, 40, 50)

The * before the parameter name tells Python to collect all extra positional arguments into a tuple. Inside the function, values is a regular tuple you can iterate over. The name args is only a convention. The asterisk does the actual work, so *values or *numbers would behave identically. This is how built-ins like print accept any number of arguments.

Variable Keyword Arguments with **kwargs

The same idea, but for named arguments:

def log_event(**details):
for key, value in details.items():
print(key + ": " + value)
log_event(user="maria", action="login", status="success")

Two asterisks before a parameter name tells Python to collect all extra keyword arguments into a dictionary. Inside the function, details is a regular dict mapping each provided keyword name to its value. This is useful for functions that accept flexible configuration: the caller can pass any combination of named settings and the function works through them.

Lambda Functions

A lambda is a one-line anonymous function:

def cube(x):
return x ** 3
cube = lambda x: x ** 3
combine = lambda text, n: (text + " ") * n
combine("go", 3)

The pattern is lambda parameters: expression. The expression after the colon is implicitly returned. Lambdas exist for situations where you need a quick, throwaway function and a full def block would be excessive: most commonly when passing a function as an argument to another function. Assigning a lambda to a name like cube = lambda x: ... is technically valid but frowned upon. If a function deserves a name, it usually deserves a proper def with a docstring.

map, filter, and reduce

These three functions apply another function to a sequence.

map transforms every item:

scores = [45, 62, 78, 91]
adjusted = list(map(lambda x: round(x * 1.1, 1), scores))

map applies the function to each item and returns a lazy iterator. Wrapping in list() materialises the results. The output is always the same length as the input.

filter keeps only the items that pass a test:

products = ["notebook", "pen", "laptop stand", "cable organiser"]
long_names = list(filter(lambda name: len(name) > 5, products))

Only items where the function returns True make it through. The output is shorter than or equal to the input.

reduce collapses a sequence to a single value:

from functools import reduce
tags = ["python", "data", "analysis"]
combined = reduce(lambda a, b: a + "-" + b, tags)

reduce applies the function to the first two items, then uses the result as the first argument for the next item, continuing until one value remains. reduce lives in functools because Python’s creator felt most real-world use cases are better served by specialised built-ins like summaxmin, or ''.join(). Reach for reduce when the operation is genuinely custom.

Error Handling

try and except let code attempt something that might fail and recover gracefully instead of crashing:

def safe_divide(numerator, denominator=1):
try:
return round(numerator / denominator, 4)
except (TypeError, ZeroDivisionError):
print("Inputs must be numbers and denominator cannot be zero.")
return None

Python runs the code inside try. If it succeeds, the except block is skipped. If anything goes wrong, Python jumps to except. Naming the specific exception types you expect, like TypeError and ZeroDivisionError, is better practice than a bare except:, which would silently swallow bugs you did not anticipate.

raise goes in the other direction: instead of handling errors, you create them intentionally to enforce rules:

def apply_discount(price, discount_pct):
if discount_pct < 0 or discount_pct > 100:
raise ValueError("discount_pct must be between 0 and 100")
return round(price * (1 - discount_pct / 100), 2)

If a function receives input it cannot sensibly handle, raising an error alerts the caller immediately rather than silently producing a wrong result. Choose the most specific exception type for the situation: ValueError for bad values, TypeError for wrong types, KeyError for missing dictionary keys. Good error handling combines both patterns: raise to enforce the contract at entry points, and try/except to handle failures from operations you do not fully control, such as file reads or network calls.

Quick Reference

ConceptCode
Define functiondef name(params):
Docstring"""description""" inside function
Return valuereturn value
Return multiplereturn (val1, val2)
Unpack tuplea, b = my_tuple
Default argumentdef f(x, y=10):
Variable positional argsdef f(*args): collects into tuple
Variable keyword argsdef f(**kwargs): collects into dict
Modify globalglobal var_name inside function
Modify enclosingnonlocal var_name inside nested function
Lambdalambda x: x * 2
Maplist(map(lambda x: x + 1, lst))
Filterlist(filter(lambda x: x > 0, lst))
Reducereduce(lambda a, b: a + b, lst)
Catch errortry: ... except TypeError: ...
Raise errorraise ValueError("message")

Parameter Order

def f(required, optional=default, *args, **kwargs):

Python enforces this order strictly: required parameters first, then parameters with defaults, then *args for any extra positional arguments collected into a tuple, then **kwargs for any extra keyword arguments collected into a dictionary. You can use any subset of these, but the order cannot change.

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 Datalad - Data Science and ML

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

Continue reading