What is it? #
A function is a named block of code you can run whenever you need it. You define it once with def, then call it as many times as you like.
Functions take inputs, called parameters, and hand back an output with return. A function without a return gives you None.
The point is not just avoiding repetition. A good function also gives a name to an idea, so the calling code reads like a description of what happens rather than a wall of steps.
Names created inside a function are local to it. They disappear when the function ends and cannot be seen from outside. That isolation is what makes functions safe to reuse.
Think of it like this #
A function is a kitchen appliance. A blender does not care whose fruit goes in; you hand it ingredients, it does its one job, and it gives you back a result. You do not need to know how the motor works to use it, and the mess stays inside the jug.
Simple example #
You keep calculating the final price of an order: subtotal, discount, tax, shipping. Instead of repeating those four lines in five places, you write one function with sensible defaults and call it everywhere.
Code #
def final_price(subtotal, discount=0.0, tax_rate=0.18, shipping=0.0):
"""Return the amount a customer actually pays."""
discounted = subtotal - discount
tax = discounted * tax_rate
return round(discounted + tax + shipping, 2)
print(final_price(1000)) # 1180.0
print(final_price(1000, discount=100)) # 1062.0
print(final_price(1000, shipping=50, tax_rate=0)) # 1050.0
# Returning more than one value
def split_name(full_name):
parts = full_name.strip().split(" ", 1)
first = parts[0]
last = parts[1] if len(parts) > 1 else ""
return first, last
first, last = split_name(" Anita Desai ")
print(first, "|", last) # Anita | Desai
# The mutable default trap
def add_tag_broken(tag, tags=[]): # created ONCE, shared forever
tags.append(tag)
return tags
print(add_tag_broken("a")) # ['a']
print(add_tag_broken("b")) # ['a', 'b'] — surprise
def add_tag(tag, tags=None): # the correct pattern
if tags is None:
tags = []
tags.append(tag)
return tags
print(add_tag("a"), add_tag("b")) # ['a'] ['b']
How it works #
def final_price(subtotal, discount=0.0, ...) defines four parameters. subtotal is required; the other three have defaults, so callers only pass what differs from the usual case.
The triple-quoted line is a docstring. It is the standard way to say what a function returns, and tools and editors read it.
final_price(1000, discount=100) uses a keyword argument. Naming arguments at the call site makes the code readable and protects you from breaking calls when parameter order changes later.
return round(..., 2) sends one value back. Execution of the function stops at return — anything after it never runs.
split_name returns two values. Python packs them into a tuple and the caller unpacks them, which is why first, last = split_name(...) works.
The last block is the classic Python gotcha. Default values are evaluated once, when the function is defined, not on each call. So tags=[] creates a single list that every call shares, and it keeps growing. The fix is None as the default and creating a fresh list inside. Make this a habit: never use a list, dict or set as a default value.
Real-world use #
Functions are the first and most important unit of structure in any codebase. Before classes, before modules, before architecture, there are functions with clear names and honest return values.
A function that is easy to test is usually a function that takes its inputs as parameters and returns a result, rather than reading global state and printing. That is why "pass data in, return data out" is the default advice — it makes unit testing on Day 23 almost free.
Keyword arguments are heavily used in real APIs and libraries, because a call like send_email(to=user.email, subject=subject, html=body, retry=True) documents itself. Positional arguments beyond two or three quickly become unreadable.
Common mistakes #
- Using a list or dictionary as a default argument. It is created once and shared across all calls — use None and build it inside.
- Forgetting
return, then wondering why the caller got None. - Writing a function that both computes and prints. Return the value; let the caller decide how to display it.
- Depending on global variables instead of parameters. It makes the function impossible to test in isolation.
- Letting a function grow past a screen. If you need a comment to mark sections inside it, those sections want to be functions.
Practice #
Write monthly_emi(principal, annual_rate, months) that returns the monthly instalment, rounded to two decimals. Give annual_rate a sensible default. Then write summarise(loans) that takes a list of loan tuples and returns both the total and the average instalment.