🌱 Beginner

Functions

📖 Lesson 11 ⏱ 30 min 🧪 5 questions 💻 3 exercises

🎯 Learning Objectives

  • Define and call functions with def
  • Use parameters, default values, and keyword arguments
  • Return values (including multiple values via tuples)
  • Understand *args and **kwargs for flexible signatures
  • Write docstrings and understand why documentation matters
  • Treat functions as first-class objects (pass them around, store them)

Defining Functions

A function is a reusable block of code that performs a specific task. You define it once with def, then call it whenever you need it.

# Definition
def greet():
    print("Hello, World!")

# Calling the function
greet()   # Hello, World!
greet()   # Hello, World!

# Functions must be defined BEFORE they are called
# greet_user()  ← NameError if defined below this line

def greet_user():
    print("Hi there!")
define_call.py
def is a statement that creates a function object and binds it to a name. The body (indented block) doesn't run until the function is called.

Parameters & Arguments

Parameters are the variables listed in the function definition. Arguments are the actual values you pass when calling.

# One parameter
def greet(name):
    print(f"Hello, {name}!")

greet("Alice")   # Hello, Alice!

# Multiple parameters
def add(a, b):
    return a + b

result = add(3, 5)  # 8

# Default values
def power(base, exponent=2):
    return base ** exponent

print(power(3))      # 9  (exponent defaults to 2)
print(power(2, 10))  # 1024

# Keyword arguments (call by name)
def create_user(name, age, city="Unknown"):
    return {"name": name, "age": age, "city": city}

user = create_user(name="Alice", age=30, city="London")
user2 = create_user(age=25, name="Bob")  # order doesn't matter with keywords
parameters.py
Default values are evaluated once when the function is defined. Never use a mutable default like def f(items=[]): — all calls share the same list! Use None instead and create inside the function.
# ❌ Mutable default — shared between calls!
def add_item_bad(item, items=[]):
    items.append(item)
    return items

print(add_item_bad("a"))  # ['a']
print(add_item_bad("b"))  # ['a', 'b'] ← BUG! Expected ['b']

# ✓ Correct pattern
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

print(add_item("a"))  # ['a']
print(add_item("b"))  # ['b'] ✓
mutable_default.py

Return Values

return sends a value back to the caller. Without return (or with a bare return), the function returns None.

# Return a single value
def square(n):
    return n ** 2

result = square(4)  # 16

# Return multiple values (as a tuple)
def min_max(numbers):
    return min(numbers), max(numbers)

lo, hi = min_max([3, 1, 4, 1, 5])
print(lo, hi)  # 1 5

# Early return (guard clause pattern)
def divide(a, b):
    if b == 0:
        return None  # or raise an exception
    return a / b

# Functions without return give None
def say_hello(name):
    print(f"Hello, {name}")

result = say_hello("Alice")
print(result)  # None
return_values.py
A function can have multiple return statements (e.g. in different branches). Execution stops at the first return it hits.

Positional-Only & Keyword-Only Parameters

# Positional-only (before /) — Python 3.8+
def distance(x1, y1, x2, y2, /):
    return ((x2-x1)**2 + (y2-y1)**2) ** 0.5

distance(0, 0, 3, 4)       # ✓ 5.0
# distance(x1=0, y1=0, ...)  # ✗ TypeError

# Keyword-only (after *)
def connect(host, port, *, timeout=30, retries=3):
    print(f"Connecting to {host}:{port}")

connect("localhost", 8080, timeout=5)   # ✓
# connect("localhost", 8080, 5)          # ✗ TypeError — timeout must be named

# Combined: positional-only / regular / keyword-only
def example(a, b, /, c, d, *, e, f):
    pass
# a, b: positional only
# c, d: either positional or keyword
# e, f: keyword only
pos_kw.py

*args and **kwargs

These let you write functions that accept a variable number of arguments:

SyntaxCollectsType inside function
*argsExtra positional argumentstuple
**kwargsExtra keyword argumentsdict
# *args — variable positional arguments
def total(*numbers):
    return sum(numbers)

print(total(1, 2, 3))        # 6
print(total(10, 20, 30, 40)) # 100

# **kwargs — variable keyword arguments
def build_profile(**info):
    return info

profile = build_profile(name="Alice", age=30, city="London")
print(profile)  # {'name': 'Alice', 'age': 30, 'city': 'London'}

# Combining everything
def flexible(required, *args, default=10, **kwargs):
    print(f"required: {required}")
    print(f"args: {args}")
    print(f"default: {default}")
    print(f"kwargs: {kwargs}")

flexible("hello", 1, 2, 3, default=99, extra="data")
# required: hello
# args: (1, 2, 3)
# default: 99
# kwargs: {'extra': 'data'}
args_kwargs.py

Unpacking arguments when calling

# Spread a list into positional args
nums = [1, 2, 3]
print(total(*nums))  # 6

# Spread a dict into keyword args
config = {"host": "localhost", "port": 8080, "timeout": 5}
connect(**config)  # same as connect(host="localhost", port=8080, timeout=5)
unpacking.py

Docstrings

A docstring is a string literal that immediately follows the function definition. It documents what the function does, its parameters, and return value.

def calculate_bmi(weight_kg, height_m):
    """Calculate Body Mass Index (BMI).

    Args:
        weight_kg: Weight in kilograms.
        height_m: Height in meters.

    Returns:
        BMI as a float, rounded to 1 decimal place.

    Raises:
        ValueError: If height is zero or negative.
    """
    if height_m <= 0:
        raise ValueError("Height must be positive")
    return round(weight_kg / height_m ** 2, 1)

# Access the docstring
print(calculate_bmi.__doc__)
help(calculate_bmi)
docstrings.py
Write docstrings for any function that isn't trivially obvious. Follow the Google or NumPy docstring style. Tools like Sphinx and your IDE use docstrings to generate documentation and show hints.

Functions as First-Class Objects

In Python, functions are objects — you can assign them to variables, pass them as arguments, store them in data structures, and return them from other functions.

# Assign to a variable
def shout(text):
    return text.upper() + "!"

yell = shout  # no parentheses — not calling, just referencing
print(yell("hello"))  # HELLO!

# Pass as an argument
def apply_twice(func, value):
    return func(func(value))

print(apply_twice(shout, "hey"))  # HEY!!

# Store in a data structure
operations = {
    "add": lambda a, b: a + b,
    "sub": lambda a, b: a - b,
    "mul": lambda a, b: a * b,
}
print(operations["add"](3, 5))  # 8

# Return from a function (function factory)
def make_multiplier(n):
    def multiplier(x):
        return x * n
    return multiplier

double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5))   # 10
print(triple(5))   # 15
first_class.py
Functions that take other functions as arguments or return them are called higher-order functions. Built-in examples: map(), filter(), sorted(key=...). We'll explore these fully in Lesson 17.

Type Hints (Preview)

Python 3.5+ supports optional type annotations. They don't change runtime behavior but help editors, linters, and teammates understand your code:

def greet(name: str) -> str:
    return f"Hello, {name}!"

def calculate_area(width: float, height: float) -> float:
    return width * height

def find_user(user_id: int) -> dict | None:  # Python 3.10+ syntax
    # returns a dict or None if not found
    ...

# Type hints are NOT enforced at runtime!
greet(42)  # works fine, just bad practice
type_hints_preview.py
We'll cover type hints comprehensively in Lesson 30. For now, know they exist and start using them for clarity — your editor will reward you with better autocomplete.
🤖

Ask your AI tutor! Not sure when to use *args vs explicit parameters? Want help writing good docstrings? Functions are the building blocks of all serious Python programs — ask anything.

💻 Exercises

01 Temperature Converter Functions

Write two functions: celsius_to_fahrenheit(c) and fahrenheit_to_celsius(f). Each should take a number, convert it, and return the result rounded to 1 decimal. Add docstrings. Test both with at least 3 values.

Show solution
def celsius_to_fahrenheit(c):
    """Convert Celsius to Fahrenheit."""
    return round(c * 9 / 5 + 32, 1)

def fahrenheit_to_celsius(f):
    """Convert Fahrenheit to Celsius."""
    return round((f - 32) * 5 / 9, 1)

# Tests
print(celsius_to_fahrenheit(0))    # 32.0
print(celsius_to_fahrenheit(100))  # 212.0
print(celsius_to_fahrenheit(37))   # 98.6

print(fahrenheit_to_celsius(32))   # 0.0
print(fahrenheit_to_celsius(212))  # 100.0
print(fahrenheit_to_celsius(98.6)) # 37.0
02 Flexible Stats

Write a function stats(*numbers) that accepts any number of numeric arguments and returns a dictionary with keys: "count", "sum", "min", "max", "average". Handle the edge case of zero arguments gracefully.

Show solution
def stats(*numbers):
    """Calculate basic statistics for given numbers.

    Returns:
        dict with count, sum, min, max, average.
        Returns empty stats if no numbers given.
    """
    if not numbers:
        return {"count": 0, "sum": 0, "min": None, "max": None, "average": None}

    return {
        "count": len(numbers),
        "sum": sum(numbers),
        "min": min(numbers),
        "max": max(numbers),
        "average": round(sum(numbers) / len(numbers), 2)
    }

print(stats(10, 20, 30, 40))
# {'count': 4, 'sum': 100, 'min': 10, 'max': 40, 'average': 25.0}

print(stats())
# {'count': 0, 'sum': 0, 'min': None, 'max': None, 'average': None}
03 Function Composer

Write a function compose(f, g) that takes two functions and returns a new function that applies g first, then f to the result. Test it: compose double (x*2) and add_one (x+1) — calling the composed function with 5 should give 12 (add_one(5)=6, then double(6)=12).

Show solution
def compose(f, g):
    """Return a new function that applies g then f."""
    def composed(x):
        return f(g(x))
    return composed

def double(x):
    return x * 2

def add_one(x):
    return x + 1

double_after_add = compose(double, add_one)
print(double_after_add(5))   # 12 → add_one(5)=6, double(6)=12

add_after_double = compose(add_one, double)
print(add_after_double(5))   # 11 → double(5)=10, add_one(10)=11