Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Beginner+150 XP

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.

55 min August 2026
// Part 01 — Defining and Calling Functions

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.

Defining and calling 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.

🎯 Pro Tip
Function names follow the same 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.

Parameter vs argument
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 time

Positional 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.

Positional vs keyword arguments
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 call
// Part 02 — Default Parameter Values

Default 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.

Default parameter values
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 keyword
⚠️ Important
Any parameter with a default value must come after every parameter without one. def 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.

The trap — a bug that looks impossible
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.

The fix — use None as the default, create the real default inside the function
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 lists
⚠️ Important
This is a genuinely common real-world bug, not a theoretical one. It most often shows up as a function that "remembers" data across calls that should have been independent — a growing list nobody added items to on purpose, or a dict that unexpectedly contains keys from a completely different request. The rule to memorise permanently: never use a mutable object (a list, dict, or set) as a default parameter value. Use None as the default and create the real mutable object inside the function body.
// Part 03 — *args and **kwargs

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.

*args — variable positional arguments
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()                  # ()          -> 0
**kwargs — variable keyword arguments
def 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.

Combining regular parameters, *args, and **kwargs
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}
💡 Note
You will use *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.
// Part 04 — Return Values

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.

return vs print — a critical distinction
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)   # 5
⚠️ Important
This is a genuinely common early mistake: writing a function that print()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."

Implicit None return
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 value

Returning 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.

Returning multiple values
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

return stops the function immediately
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))   # Adult
// Part 05 — Docstrings

Docstrings — 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.

A properly documented function
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 directly

Triple-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.

🎯 Pro Tip
There are several competing docstring formats in real use — Google style (shown above), NumPy style, and reStructuredText style are the three most common. None is objectively "correct" — the important thing is picking one and using it consistently across a codebase, so documentation tools can parse it predictably. You will see this formalised further once you reach the Best Practices module.
// Part 06 — Scope

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.

Local variables don't leak out
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 place

A 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.

Functions can READ a global variable without any special syntax
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.0
But assigning to a global name from inside a function needs the global keyword
counter = 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.
The global keyword — explicitly declaring intent to modify the global
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 modified
⚠️ Important
Reaching for global is usually a sign to reconsider the design, not a tool to use freely. Functions that silently modify global state are hard to test in isolation and hard to reason about, since calling them has effects beyond their return value. It is worth knowing global 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.
// Part 07 — Real World
💼 What This Looks Like at Work

An Austin Ticketing Startup's Discount Codes Leak Between Customers

Scenario — Event ticketing startup, Austin · Production bug report

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.

The function responsible
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

The fix — None as the default, a fresh list per call
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.

// Part 08 — Misconceptions

Four Misconceptions About Functions

✕ ""def greet(name, greeting="Hello"): re-evaluates greeting="Hello" fresh on every call""
Default values are evaluated exactly ONCE, when the def statement runs, not on every call. For an immutable default like a string this is invisible and harmless. For a mutable default like a list or dict, it means every call sharing the default is sharing the exact same object — the mutable-default trap from Part 02.
✕ ""print() and return basically do the same thing — showing the result""
print() only displays a value in the terminal; the function itself still returns None unless a separate return statement exists. return hands a real value back to the calling code, where it can be stored, passed on, or used in further computation. Confusing the two is one of the most common sources of a mysterious None appearing where a real value was expected.
✕ ""A function can only return one value""
Formally true, but practically not a limitation — a function can return a single tuple containing as many values as needed, and Python's unpacking syntax (lowest, highest = get_min_max(...)) makes this feel exactly like returning multiple values.
✕ ""You need the global keyword any time a function uses a variable defined outside it""
global is only needed when a function needs to ASSIGN to (rewrite) a global variable. Simply reading a global variable's current value from inside a function works with no special syntax at all — the global keyword exists specifically to resolve the ambiguity that arises only when assignment is involved.
// Part 09 — Interview Prep

5 Interview Questions — With Complete Answers

Explain the mutable default argument trap in Python, and how to avoid it.
Default parameter values are evaluated exactly once, at the time the function is defined, not once per call. If that default is a mutable object like a list or dict, every call that relies on the default shares the exact same object across calls, and mutations from one call (like an append) persist and are visible in later calls. The standard fix is to use None as the default and create the real mutable object inside the function body: if arg is None: arg = [].
What is the difference between print() and return inside a function?
print() writes text to the terminal for a human to read, but does not make any value available to the calling code — a function that only prints still returns None. return sends a value back to the caller, where it can be stored in a variable, passed to another function, or used in further computation. Confusing the two is a common source of unexpected None values.
What are *args and **kwargs, and when would you use them?
*args collects any number of extra positional arguments into a tuple inside the function; **kwargs collects any number of extra keyword arguments into a dict. They are used when a function needs to accept a flexible, not-known-in-advance number of arguments — common in wrapper functions, decorators, and library code that needs to remain forward-compatible with arguments it does not need to inspect directly.
What happens if a function has no return statement, or a bare return with no value?
It implicitly returns None. This is not a special case or an error — it is Python's consistent way of representing "this function call produced no meaningful value," identical to the None you would get from a function that explicitly wrote "return None".
Why does counter += 1 inside a function raise an UnboundLocalError if counter is a global variable, without the global keyword?
Python decides whether a name is local or global for an ENTIRE function body at compile time, based on whether that name is ever assigned to anywhere inside the function. Since counter += 1 contains an assignment to counter, Python treats counter as local for the whole function — including on the read side of +=, which happens before any local value has been assigned, causing the error. The global keyword tells Python explicitly that assignments to that name inside the function should modify the global variable instead of creating a new local one.
// Common Mistakes

Function Mistakes Beginners Make Constantly

Using a mutable default argument (=[] or ={})
As shown in Part 02, this creates one shared object reused across every call that omits the argument. Always use None as the default and create the mutable object inside the function body instead.
Forgetting that a function without return gives back None
A function that only prints its result still returns None. Trying to use that None in further computation (like adding it to a number) raises a TypeError. Add an explicit return if the value needs to be used again.
Putting a parameter without a default after one that has one
def f(a=1, b): is a SyntaxError. Parameters with default values must come after every parameter without a default — Python needs to be able to tell which arguments are optional purely from their position in the parameter list.
Trying to modify a global variable from inside a function without the global keyword
Assigning to a name inside a function makes Python treat it as local for the entire function body, causing an UnboundLocalError if that name is also read before the assignment. Use global explicitly if a function genuinely needs to modify a variable defined outside it — though passing values in and returning results out is usually the better design.
Confusing a function's docstring with a regular comment
A docstring must be the very first statement inside the function body, as a string literal — not a # comment. Only a properly placed docstring is accessible via help() or function.__doc__, and only it is picked up by documentation-generation tools.
// Error Library

Errors You Will Hit With Functions — And Exactly Why

TypeError: greet() missing 1 required positional argument: 'name'
Cause: The function was called without supplying a value for a parameter that has no default — Python cannot proceed without it.
Fix: Pass the missing argument, either positionally or by keyword, or give the parameter a default value in the function definition if it should genuinely be optional.
TypeError: greet() takes 1 positional argument but 2 were given
Cause: Too many arguments were passed at the call site — more than the function's parameter list can accept, and the function has no *args to absorb the extra ones.
Fix: Check the function's definition for exactly how many parameters it expects, and remove the extra argument(s) or add *args if the function is genuinely meant to accept a variable number.
UnboundLocalError: local variable 'counter' referenced before assignment
Cause: A variable is assigned to somewhere inside a function (making Python treat it as local for the whole function body) but is read on an earlier line within that same function, before any local value has actually been assigned — commonly caused by counter += 1 on a name that was meant to refer to a global.
Fix: Add "global counter" at the top of the function if you genuinely intend to modify the global variable, or rename the local variable if the collision was accidental.
SyntaxError: non-default argument follows default argument
Cause: A required parameter (no default value) was placed after an optional one (with a default) in the function's parameter list.
Fix: Reorder the parameters so every parameter with a default value comes after every parameter without one.
NameError: name 'subtotal' is not defined (used outside the function it was created in)
Cause: A variable created inside a function only exists in that function's local scope — it does not exist at all once the function returns, and was never visible outside it in the first place.
Fix: Return the value from the function and capture it in a variable in the calling code if it needs to be used elsewhere: result = calculate_total().

🎯 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, Methods
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...