🎯 Learning Objectives
- Define and call functions with
def - Use parameters, default values, and keyword arguments
- Return values (including multiple values via tuples)
- Understand
*argsand**kwargsfor 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
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
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:
| Syntax | Collects | Type inside function |
|---|---|---|
*args | Extra positional arguments | tuple |
**kwargs | Extra keyword arguments | dict |
# *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
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
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
Primary sources: Python Docs — Defining Functions · Python Docs — More on Functions
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
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
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}
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