Functions — Defining, Parameters, Return Values
def syntax, parameters vs arguments, default argument values and the mutable-default trap, *args/**kwargs, return values, docstrings, and the basics of scope.
def — Packaging Behaviour Into a Reusable Name
A function is a named, reusable block of code. You have already been calling functions constantly — print(), len(), range() — without writing them yourself. This module is about writing your own. The motivation is one of the oldest ideas in programming, usually shortened to DRY: Don't Repeat Yourself. Any time you find yourself copying and pasting a block of logic with only small tweaks, that block is a strong candidate to become a function.
def greet(name):
print(f"Hello, {name}!")
greet("Maria")
greet("Jordan")
# Hello, Maria!
# Hello, Jordan!The def keyword starts a function definition, followed by the function's name, a parenthesised list of parameters, and a colon — the same colon-plus-indented-block structure you already know from if, for, and while. Defining a function does not run its body — the code inside only executes when the function is called, using its name followed by parentheses.
snake_case convention as variable names — calculate_total, not calculateTotal or CalculateTotal (the latter is reserved by convention for class names, covered in the Object-Oriented Python phase). A good function name describes what it does, usually starting with a verb — send_email, validate_input, get_user.Parameters vs arguments — a distinction worth being precise about
These two words are often used interchangeably in casual conversation, but they mean specifically different things, and using them precisely will make you sound — and think — more like an experienced engineer. A parameter is the name listed in the function definition. An argument is the actual value passed in when the function is called.
def greet(name): # "name" is a PARAMETER — part of the function's definition
print(f"Hello, {name}!")
greet("Maria") # "Maria" is an ARGUMENT — the actual value supplied at call timePositional and keyword arguments
Arguments can be passed positionally — matched to parameters purely by order — or by keyword, naming the parameter explicitly at the call site. Keyword arguments can be given in any order, and they make a call far more self-documenting when a function takes several parameters.
def describe_pet(name, species, age):
print(f"{name} is a {age}-year-old {species}")
describe_pet("Rex", "dog", 3) # positional — order matters
describe_pet(name="Rex", species="dog", age=3) # keyword — order doesn't matter
describe_pet(age=3, name="Rex", species="dog") # same result, reordered
describe_pet("Rex", age=3, species="dog") # mixing is fine —
# but positional arguments must always come before keyword arguments in the same callDefault Values — And the Mutable-Default Trap That Catches Everyone Once
A parameter can be given a default value, making it optional at the call site — if the caller does not supply an argument for it, the default is used instead.
def greet(name, greeting="Hello"):
print(f"{greeting}, {name}!")
greet("Maria") # Hello, Maria! — uses the default
greet("Jordan", "Hi") # Hi, Jordan! — overrides the default
greet("Priya", greeting="Welcome") # Welcome, Priya! — same, by keyworddef greet(greeting="Hello", name): is a SyntaxError — Python cannot figure out which arguments correspond to which parameters if a required one follows an optional one.The mutable-default-argument trap
This is one of the most famous gotchas in the entire language, and it catches nearly every Python engineer at least once — usually in a way that produces deeply confusing behaviour that looks like it must be a bug in Python itself. It is not. It is a direct, logical consequence of something you already know from the Variables module: default values are evaluated once, when the function is defined — not once per call — and if that default is a mutable object like a list, every call that relies on the default shares the exact same object.
def add_item(item, cart=[]): # DANGER — a mutable default value
cart.append(item)
return cart
print(add_item("apple")) # ['apple']
print(add_item("banana")) # ['apple', 'banana'] — wait, what?
print(add_item("cherry")) # ['apple', 'banana', 'cherry'] — it keeps growing!
# Every call that didn't pass its own "cart" is silently sharing
# the SAME list object, created exactly once, back when the function was defined.Each of these calls looks, at the call site, like it should start from a fresh empty list — that is what the default =[] visually suggests. But it does not. The empty list is created a single time, when Python processes the def statement, and every subsequent call that omits the cart argument reuses that exact same list object, mutating it further on every call, exactly like the backup = scores example from the Variables module.
def add_item(item, cart=None):
if cart is None:
cart = [] # a BRAND NEW list, created fresh on every call
cart.append(item)
return cart
print(add_item("apple")) # ['apple']
print(add_item("banana")) # ['banana'] — correct, independent listsNone as the default and create the real mutable object inside the function body.Accepting a Variable Number of Arguments
Sometimes you cannot know in advance how many arguments a function needs to accept. *args collects any number of extra positional arguments into a tuple, and **kwargs collects any number of extra keyword arguments into a dict. Neither name is a keyword itself — args and kwargs are just convention; the single * and double ** are what actually matter.
def total(*args):
print(args) # a tuple of everything passed in
return sum(args)
total(1, 2, 3) # (1, 2, 3) -> 6
total(10, 20) # (10, 20) -> 30
total() # () -> 0def build_profile(**kwargs):
print(kwargs) # a dict of every keyword argument passed in
return kwargs
build_profile(name="Maria", age=28, city="Austin")
# {'name': 'Maria', 'age': 28, 'city': 'Austin'}The two can appear together, and Python has a strict, sensible order for parameters: regular positional parameters first, then *args, then regular keyword parameters with defaults, then **kwargs last.
def log_event(event_name, *details, level="INFO", **metadata):
print(f"[{level}] {event_name}")
print("Details:", details)
print("Metadata:", metadata)
log_event("user_login", "192.168.1.1", "mobile", level="WARNING", user_id=42, retries=2)
# [WARNING] user_login
# Details: ('192.168.1.1', 'mobile')
# Metadata: {'user_id': 42, 'retries': 2}*args/**kwargs more as a reader than a writer at first — they show up constantly in library and framework code that needs to accept flexible, forward-compatible arguments (wrapper functions, decorators — covered in full in Module 29 — and many popular libraries you will use later in this track). Recognising the syntax now means you will not be confused the first time you see it in someone else's code.return — Sending a Value Back to the Caller
return immediately ends the function and sends a value back to wherever the function was called from. Unlike print(), which only displays a value in the terminal, return makes a value available for the calling code to store, pass to another function, or use in a further calculation.
def add_print(a, b):
print(a + b) # only displays the result — does NOT hand it back
def add_return(a, b):
return a + b # hands the result back to the caller
result1 = add_print(2, 3) # prints "5", but result1 is None — nothing was returned
result2 = add_return(2, 3) # prints nothing, but result2 is 5
print(result1) # None
print(result2) # 5print()s its answer, then trying to use the function's "result" in further code, and getting None everywhere. If a value needs to be used again — stored, passed on, computed with further — it must be returned, not merely printed.Every function returns something — even if you never write return
A function with no return statement at all, or a bare return with no value after it, implicitly returns None. This is not an error or a special case — it is the same None you already know from the Variables module, and it is Python's consistent way of saying "this function produced no meaningful value."
def log_message(msg):
print(f"LOG: {msg}")
# no return statement at all
result = log_message("Server started")
print(result) # None — the function's job was printing, not producing a valueReturning multiple values with a tuple
Python functions can only formally return one value — but that one value can itself be a tuple containing several values, and Python makes packing and unpacking a tuple like this almost invisible at the call site. This is an extremely common, genuinely idiomatic pattern.
def get_min_max(numbers):
return min(numbers), max(numbers) # this is actually returning ONE tuple: (min, max)
lowest, highest = get_min_max([4, 8, 15, 16, 23, 42])
print(lowest) # 4
print(highest) # 42
# You can also just capture the whole tuple directly:
result = get_min_max([4, 8, 15, 16, 23, 42])
print(result) # (4, 42)return min(numbers), max(numbers) works because a comma outside of brackets creates a tuple — the parentheses around a tuple are usually optional. This "returning multiple values" pattern is really just a function returning a tuple, and the caller unpacking it into separate names in one line — the same unpacking mechanic you will see formalised for tuples specifically in Module 09.
return exits immediately — code after it in the same block never runs
def check_age(age):
if age < 0:
return "Invalid age"
if age < 18:
return "Minor"
return "Adult"
print("This line never runs") # unreachable — return already exited the function
print(check_age(25)) # AdultDocstrings — Documentation That Lives Inside the Function Itself
A docstring is a string literal placed as the very first line inside a function's body, describing what the function does. Unlike a regular # comment, a docstring is stored as part of the function object itself and can be read back programmatically — by the built-in help() function, by IDEs showing a tooltip when you call the function, and by documentation-generation tools.
def calculate_discount(price, percent):
"""
Calculate the price after applying a percentage discount.
Args:
price: The original price, as a float.
percent: The discount percentage (0-100).
Returns:
The discounted price, as a float.
"""
return price * (1 - percent / 100)
help(calculate_discount) # prints the docstring above, formatted, to the terminal
print(calculate_discount.__doc__) # accesses the raw docstring directlyTriple-quoted strings ("""...""") are used for docstrings by convention, even for a single line, since they allow the docstring to span multiple lines cleanly if it grows later without needing to change the quote style. Not every function needs a full Args/Returns docstring — a short, obvious helper function is often fine with no docstring at all, or a single descriptive line. Public functions in a shared codebase, the kind other engineers will call without reading their implementation, are where docstrings earn their keep.
Local vs Global Scope — Where a Variable Actually Lives
A variable created inside a function only exists inside that function — this is called local scope. Once the function returns, its local variables are gone entirely; they cannot be accessed from outside, and each call to the function gets its own fresh set of them.
def calculate_total():
subtotal = 100 # local to calculate_total — exists only during this call
tax = subtotal * 0.08
return subtotal + tax
result = calculate_total()
print(result) # 108.0
print(subtotal) # NameError: name 'subtotal' is not defined
# "subtotal" never existed outside calculate_total in the first placeA variable created outside any function, at the top level of a script or module, has global scope — it is visible from inside functions too, but only for reading, not for assignment, unless you say otherwise explicitly.
discount_rate = 0.10 # global
def apply_discount(price):
return price * (1 - discount_rate) # reading the global — this just works
print(apply_discount(100)) # 90.0counter = 0
def increment():
counter += 1 # UnboundLocalError!
return counter
# Python sees "counter = ..." anywhere inside the function and treats "counter"
# as a NEW local variable for the ENTIRE function body — even on lines before
# the assignment. Since counter += 1 needs to read counter before writing it,
# and Python has already decided counter is local (and therefore not yet
# assigned at that point), this fails.counter = 0
def increment():
global counter # tells Python: "counter" here refers to the global, not a new local
counter += 1
return counter
print(increment()) # 1
print(increment()) # 2
print(counter) # 2 — the global itself was actually modifiedglobal exists and understanding exactly why the UnboundLocalError above happens — but in most real code, passing values in as parameters and getting results back via return is the better default. Full scope rules — including how nested functions resolve names through enclosing scopes — get their own dedicated treatment in the Closures and Scope module (Module 31) later in this track; this is deliberately just the foundation.An Austin Ticketing Startup's Discount Codes Leak Between Customers
A customer support ticket comes in at an Austin concert-ticketing startup: a customer says their checkout shows promo codes they never entered, applied to an order that should have had none. Within an hour, three more nearly identical tickets arrive. The order totals are wrong, and the discrepancy is growing — this looks, at first glance, like it might be a security issue.
What the engineer finds
The checkout function that assembles a customer's applied promo codes has exactly the shape shown in Part 02 above — a mutable default argument.
def build_checkout(customer_id, promo_codes=[]):
promo_codes.append("WELCOME10") # auto-applies a standing welcome discount
return {"customer_id": customer_id, "codes": promo_codes}Every call that does not explicitly pass its own promo_codes list shares the exact same list object — created once, when the server process started and the function was defined, not once per request. Each checkout appends "WELCOME10" to that shared list and never clears it, so the list grows across every request the server handles, and every customer after the first one sees an ever-growing list of codes that were never theirs.
The fix, and why it mattered more than usual
def build_checkout(customer_id, promo_codes=None):
if promo_codes is None:
promo_codes = []
promo_codes.append("WELCOME10")
return {"customer_id": customer_id, "codes": promo_codes}This bug is genuinely dangerous in a way many bugs are not, precisely because of where it hid: a long-running server process calls the same function thousands of times without ever restarting, so the shared mutable default keeps accumulating state across completely unrelated customers' requests for as long as the process stays up. A quick local test — calling the function once or twice and restarting the script each time — would never have surfaced it, which is exactly why this class of bug tends to reach production before anyone notices.
Four Misconceptions About Functions
5 Interview Questions — With Complete Answers
Function Mistakes Beginners Make Constantly
Errors You Will Hit With Functions — And Exactly Why
🎯 Key Takeaways
- ✓A parameter is the name in the function definition; an argument is the actual value passed at the call site. Arguments can be positional (matched by order) or keyword (matched by name).
- ✓Default parameter values are evaluated exactly once, when the function is defined — never use a mutable object (list, dict, set) as a default. Use None and create the real object inside the function body.
- ✓*args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict.
- ✓print() only displays a value; return hands it back to the caller for actual use. A function with no return statement implicitly returns None.
- ✓A function can only formally return one value, but that value can be a tuple, which Python's unpacking syntax makes feel like returning multiple values.
- ✓Docstrings are string literals placed as the first line inside a function, accessible via help() and function.__doc__ — unlike a regular comment.
- ✓Variables created inside a function are local — they do not exist outside it. Functions can read global variables freely, but need the global keyword to assign to one.
- ✓Full scope rules, including nested functions and closures, get a dedicated module (31) later in this track — this module is deliberately just the foundation.
What comes next
Module 08 covers lists in depth — the workhorse data structure of Python — indexing, slicing, every common method, and the mutability behaviour this module's mutable-default trap was really foreshadowing.
Module 08 → Lists — Creation, Indexing, MethodsDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.