Part 1 · Foundations · Module 05

Functions

A function is a named, reusable piece of behaviour. Functions are how you stop repeating yourself, how you give a name to an idea, and how you split a big problem into small ones you can test one at a time. Almost every line of professional Python lives inside one.

⏱ ~2.5 hoursPrerequisites: M04 Control Flow

Defining and calling a function

You have already called many functions: print(), len(), input(). Now you will write your own. A function definition starts with the keyword def, a name, a pair of parentheses holding the parameters, and a colon. The indented block underneath is the body.

def fahrenheit(celsius):
    """Convert a Celsius temperature to Fahrenheit."""
    return celsius * 9 / 5 + 32

print(fahrenheit(100))   # 212.0
print(fahrenheit(-40))   # -40.0

Three things happen when Python runs this file:

  1. The def statement runs once. It does not execute the body. It creates a function object and binds the name fahrenheit to it, exactly like an assignment.
  2. A call fahrenheit(100) creates a fresh frame: a private workspace where the parameter celsius is bound to the argument 100. The body runs inside it.
  3. return ends the call immediately and hands a value back to the caller. The frame is thrown away, and the call expression fahrenheit(100) is replaced by that value.

If a function reaches the end of its body without a return, or executes a bare return, it returns None. This is a classic beginner surprise: printing is not returning.

shout_print displays text as a side effect and returns None. shout_return gives the value back so the caller can store it, combine it, or test it. Prefer returning: the caller can always print a returned value, but it can never recover a value that was only printed.

A function can return several values by returning a tuple; the caller unpacks it:

Parameters, arguments, and defaults

Two words that people often mix up:

Arguments can be passed positionally (matched by order) or by keyword (matched by name). Keyword arguments make calls self-explanatory and let you skip the order:

def area(width, height):
    return width * height

area(3, 4)                 # positional
area(width=3, height=4)    # keyword
area(3, height=4)          # mixed: positional ones must come first
area(height=4, width=3)    # keyword order does not matter

A default value makes a parameter optional. Parameters with defaults must come after those without.

Python checks every call against the signature and raises TypeError for mismatches. Read these messages: they say exactly what is wrong.

The mutable-default trap

Here is the most famous Python gotcha. Predict the output before running it.

Most people expect ['a'] then ['b']. The real answer is ['a'] then ['a', 'b']. Why? A default value is evaluated once, when the def statement runs, and stored on the function object. Every call that omits tags gets that same list object. Appending mutates the shared list, so changes leak between calls.

Step through it in the visualizer: watch both calls' tags point at a single list.

The idiomatic fix uses None as a sentinel (a special marker meaning "no value given") and creates a new list inside the body, which runs on every call:

Flexible signatures: *args, **kwargs, /, and *

Sometimes you do not know how many arguments a caller will pass. print accepts any number; so can your functions.

The names args and kwargs are conventions; the stars do the work.

The stars also work in the other direction, at the call site: *seq spreads a sequence into positional arguments, and **mapping spreads a dict into keyword arguments.

point = (3, 4)
options = {"sep": " | ", "end": "!\n"}
print(*point, **options)      # same as print(3, 4, sep=" | ", end="!\n")  →  3 | 4!

Keyword-only and positional-only parameters

A bare * in a signature means "everything after this must be passed by keyword". A / means "everything before this must be passed by position". Together they let you design calls that are hard to misuse.

def transfer(amount, /, *, source, target, dry_run=False):
    ...

transfer(100, source="checking", target="savings")      # OK
transfer(100, "checking", "savings")                    # TypeError: source and target are keyword-only
transfer(amount=100, source="a", target="b")            # TypeError: amount is positional-only

Why would you want this?

The full order of a signature is:

def f(pos_only, /, normal, default=1, *args, kw_only, kw_default=2, **kwargs): ...

Scope: where names live (LEGB)

A scope is a region of code where a set of names is visible. Each function call gets its own local scope, so a variable created inside a function does not exist outside it, and two functions can both use a name like total without interfering.

When Python meets a name, it searches four scopes in order, called LEGB:

B · Built-in: print, len, range, ValueError … G · Global: names at the top level of the module E · Enclosing: an outer function's locals L · Local: this call's parameters and variables search starts here and moves outward →
Name lookup searches Local, then Enclosing, then Global, then Built-in, and stops at the first match.

Step through this program and watch the frames. Notice that the global x and each function's x are different variables.

Assignment makes a name local

Reading a global inside a function is fine. Assigning to a name anywhere in a function makes that name local for the whole function body, decided when the function is compiled. That produces this confusing error:

To rebind a module-level name from inside a function, declare it global. To rebind a name from an enclosing function, declare it nonlocal. Use both sparingly: a function that quietly changes globals is hard to test and reason about. Usually it is better to take the value as a parameter and return the new value.

# Avoid: hidden shared state
total = 0
def add(n):
    global total
    total += n
# Prefer: data in, data out
def add(total, n):
    return total + n

total = add(0, 5)

Functions are values: first-class functions and lambda

In Python a function is an ordinary object, as real as an int or a list. You can bind it to another name, store it in a list or dict, pass it to another function, and return it from a function. Languages with this property are said to have first-class functions.

Functions that return functions (closures)

An inner function remembers the enclosing variables it uses, even after the outer function has returned. The combination is a closure. You will use this pattern for decorators in M15.

lambda: tiny anonymous functions

lambda params: expression creates a function without a def or a name. The body must be a single expression whose value is returned automatically. Lambdas shine as short, throwaway arguments, especially the key= argument of sorted, min, and max, which tells them what to compare.

Docstrings and type hints

A docstring is a string literal that is the first statement of a function. Python stores it as fn.__doc__, and tools such as help(), editors, and documentation generators display it. This course uses the Google style: a one-line summary, a blank line, then Args:, Returns:, and Raises: sections.

Type hints (annotations) state what types a function expects and returns. Python does not enforce them at run time; they are documentation that tools can check. A type checker such as mypy (introduced in M16) reads them and reports mistakes before you run the code.

Common hint forms you will meet now: int, float, str, bool, None, list[int], dict[str, float], tuple[int, int], and unions written with a bar: int | None ("an int or None").

Pure functions and side effects

A pure function has two properties: its result depends only on its arguments, and it changes nothing outside itself. len, abs, and fahrenheit are pure. Anything else a function does besides computing its return value is a side effect: printing, writing a file, mutating an argument, changing a global, reading the clock, or asking for input.

# Impure: mutates its argument
def add_bonus(scores):
    for i in range(len(scores)):
        scores[i] += 5
    return scores
# Pure: returns a new list
def add_bonus(scores):
    return [s + 5 for s in scores]

Pure functions are easier to test (same input, same output, every time), easier to reuse, and safe to call in any order. Programs need side effects, of course, or they would do nothing visible. The professional habit is to push side effects to the edges: a small main() reads input and prints output, and calls pure functions that do the real work.

A first look at recursion

A function may call itself. This is recursion, and it fits problems that contain smaller copies of themselves. Every recursive function needs two parts:

The factorial n! = n × (n−1) × … × 1 is the classic example, because n! = n × (n−1)! and 0! = 1.

def factorial(n):
    if n == 0:                      # base case
        return 1
    return n * factorial(n - 1)     # recursive case: a smaller problem

Each call waits for the one below it. Expand the call tree to see the calls go down to the base case and the results multiply back up.

Now step through the real code: the tracer shows a stack of frames, one per active call, each with its own n.

Forget the base case, or fail to make progress towards it, and the calls never stop. Python protects itself with a recursion limit (about 1000 frames by default) and raises RecursionError:

Practice

Exercises run in the browser and are graded instantly. Start with the first and work down.

Write greet(name, greeting="Hello") that returns (not prints) a string like "Hello, Ada!". Surrounding whitespace in name should be stripped.

Hint

Use an f-string and name.strip().

Show solution
def greet(name, greeting="Hello"):
    return f"{greeting}, {name.strip()}!"

This function leaks state between calls. Fix it so each call without items starts from a new empty list, while passing a list explicitly still appends to (and returns) that list.

Hint

Default to None and test if items is None:.

Show solution
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

Write summarize(*numbers, decimals=2) returning a tuple (smallest, largest, mean), with the mean rounded to decimals. With no numbers, return None. decimals must be keyword-only (it is, automatically, because it follows *numbers).

Hint

numbers is a tuple: use min, max, sum, and len.

Show solution
def summarize(*numbers, decimals=2):
    if not numbers:
        return None
    return min(numbers), max(numbers), round(sum(numbers) / len(numbers), decimals)

Write apply_discount(price, /, *, percent) that returns price reduced by percent percent, rounded to 2 decimals. price must be positional-only and percent keyword-only. Raise ValueError if percent is outside 0–100.

Hint

The signature is def apply_discount(price, /, *, percent):.

Show solution
def apply_discount(price, /, *, percent):
    if not 0 <= percent <= 100:
        raise ValueError("percent must be between 0 and 100")
    return round(price * (1 - percent / 100), 2)

Write make_counter(start=0, step=1) that returns a function. Each call of the returned function advances the count by step and returns the new value. Separate counters must be independent. Do not use globals.

Hint

Define an inner function that declares nonlocal count, then return the inner function (without calling it).

Show solution
def make_counter(start=0, step=1):
    count = start

    def counter():
        nonlocal count
        count += step
        return count

    return counter

Write a recursive function digit_sum(n) that returns the sum of the decimal digits of a non-negative integer, for example digit_sum(4096) == 19. Do not convert to a string. Hint: the last digit is n % 10 and the rest is n // 10.

Hint

Base case: n < 10 returns n.

Show solution
def digit_sum(n):
    if n < 10:
        return n
    return n % 10 + digit_sum(n // 10)

Mini-project: a bill-splitting library

Mini-projectsplitter.py: tips and shared bills

Build a small library of pure functions for splitting restaurant bills, plus a tiny main() that is the only place anything is printed. This is the shape of most real programs: a pure core and a thin layer of input and output.

mkdir splitter && cd splitter
pixi init
pixi add python=3.14
# write splitter.py, then:
pixi run python splitter.py

Functions to write (each with a Google-style docstring and type hints):

  • tip_amount(total: float, percent: float = 18.0) -> float, rounded to cents; raise ValueError for negative inputs.
  • split_even(total: float, people: int, *, tip_percent: float = 18.0) -> list[float]: shares that add up exactly to the total with tip, giving any leftover cents to the first people (work in integer cents to avoid float drift).
  • split_by_items(items: dict[str, list[float]], *, tip_percent: float = 18.0, shared: float = 0.0) -> dict[str, float]: each person pays for their own items plus an equal part of shared, with tip applied proportionally.
  • main() -> None: asks for the total and number of people with input() and prints each share.
  • All library functions are pure: no print, no input, no globals
  • split_even(100, 3, tip_percent=0) returns [33.34, 33.33, 33.33]
  • The shares from split_even always sum to the total including tip
  • tip_percent is keyword-only everywhere it appears
  • No mutable default arguments
  • Every public function has a Google-style docstring and type hints
  • main() runs only under if __name__ == "__main__": (you will learn exactly why in M08)

Stretch goals: accept tip as either a percent or a fixed amount (two keyword-only parameters, exactly one allowed); add round_up_to(amount, step) so everyone pays a round number; write a few assert checks at the bottom of the file to test your functions.

Quiz

Recap

Going further