Functions
Table of Contents
Functions let you package up a piece of logic, give it a name, and reuse it. They’re how you organize Python programs into manageable, testable pieces. In the Introduction to Python, we called built-in functions like print() and len(). Now let’s write our own.
Defining Functions
Use the def keyword:
def greet(name):
return f"Hello, {name}!"
message = greet("Alice")
print(message) # Hello, Alice!
A function definition has:
- The
defkeyword - A name (snake_case by convention)
- Parameters in parentheses
- A colon, then an indented body
- An optional
returnstatement (returnsNoneif omitted)
Parameters and Arguments
Positional arguments
def add(a, b):
return a + b
print(add(3, 5)) # 8
Default values
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
print(greet("Alice")) # Hello, Alice!
print(greet("Bob", "Hey")) # Hey, Bob!
Never use a mutable object (like a list or dict) as a default value. It’s shared across all calls:
# Bug — the list accumulates across calls
def add_item(item, items=[]):
items.append(item)
return items
# Fix — use None and create a new list each time
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
Keyword arguments
You can pass arguments by name, in any order:
def create_user(name, email, role="viewer"):
return {"name": name, "email": email, "role": role}
user = create_user(email="alice@example.com", name="Alice", role="admin")
print(user) # {'name': 'Alice', 'email': 'alice@example.com', 'role': 'admin'}
*args — variable positional arguments
def total(*numbers):
return sum(numbers)
print(total(1, 2, 3)) # 6
print(total(10, 20, 30, 40)) # 100
*args collects extra positional arguments into a tuple.
**kwargs — variable keyword arguments
def build_config(**options):
config = {"debug": False, "port": 8080}
config.update(options)
return config
print(build_config(debug=True, host="localhost"))
# {'debug': True, 'port': 8080, 'host': 'localhost'}
**kwargs collects extra keyword arguments into a dictionary.
Combining them all
def make_request(method, url, *args, timeout=30, **kwargs):
print(f"{method} {url}")
print(f" Extra args: {args}")
print(f" Timeout: {timeout}")
print(f" Options: {kwargs}")
make_request("GET", "/api/users", "v2", timeout=10, headers={"Auth": "token"})
# GET /api/users
# Extra args: ('v2',)
# Timeout: 10
# Options: {'headers': {'Auth': 'token'}}
The order must be: positional → *args → keyword-only → **kwargs.
Return Values
Functions can return any value — or multiple values using tuples:
def divide(a, b):
if b == 0:
return None, "Cannot divide by zero"
return a / b, None
result, error = divide(10, 3)
print(result) # 3.333...
result, error = divide(10, 0)
print(error) # Cannot divide by zero
Early returns
Use early returns to handle edge cases first, keeping the main logic unindented:
def process_order(order):
if not order:
return {"error": "No order provided"}
if not order.get("items"):
return {"error": "Order has no items"}
if order["total"] <= 0:
return {"error": "Invalid total"}
# Main logic — only reached if all checks pass
return {"status": "processed", "id": generate_id()}
Scope
Variables defined inside a function are local — they don’t exist outside:
def calculate():
result = 42 # local variable
return result
calculate()
# print(result) # NameError: name 'result' is not defined
Functions can read variables from enclosing scopes, but can’t modify them without the nonlocal or global keyword:
counter = 0
def increment():
global counter # needed to modify the global variable
counter += 1
increment()
print(counter) # 1
In practice, avoid global. Pass values in and return values out — it makes functions predictable and testable.
Lambda Functions
Short anonymous functions for simple operations:
# Regular function
def double(x):
return x * 2
# Lambda equivalent
double = lambda x: x * 2
print(double(5)) # 10
Lambdas are most useful as arguments to functions like sorted, map, and filter:
users = [
{"name": "Charlie", "age": 30},
{"name": "Alice", "age": 25},
{"name": "Bob", "age": 28}
]
# Sort by age
sorted_users = sorted(users, key=lambda u: u["age"])
print([u["name"] for u in sorted_users]) # ['Alice', 'Bob', 'Charlie']
# Sort by name
sorted_users = sorted(users, key=lambda u: u["name"])
print([u["name"] for u in sorted_users]) # ['Alice', 'Bob', 'Charlie']
Docstrings
Document your functions with a docstring — the first string in the function body:
def calculate_bmi(weight_kg, height_m):
"""Calculate Body Mass Index.
Args:
weight_kg: Weight in kilograms.
height_m: Height in meters.
Returns:
BMI as a float, rounded to one decimal place.
"""
return round(weight_kg / (height_m ** 2), 1)
# Access the docstring
print(calculate_bmi.__doc__)
help(calculate_bmi)
Type Hints
Python is dynamically typed, but you can add type hints for documentation and tooling:
def greet(name: str, times: int = 1) -> str:
return (f"Hello, {name}! " * times).strip()
def find_user(users: list[dict], user_id: int) -> dict | None:
for user in users:
if user["id"] == user_id:
return user
return None
Type hints don’t enforce anything at runtime — they’re for readability and tools like mypy.
Functions as Values
In Python, functions are objects. You can pass them around like any other value:
def apply_operation(x, y, operation):
return operation(x, y)
def add(a, b):
return a + b
def multiply(a, b):
return a * b
print(apply_operation(3, 4, add)) # 7
print(apply_operation(3, 4, multiply)) # 12
Closures
A function that remembers variables from its enclosing scope:
def make_multiplier(factor):
def multiply(x):
return x * factor # "remembers" factor
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
Decorators
A decorator wraps a function to add behavior without modifying it:
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start
print(f"{func.__name__} took {elapsed:.3f}s")
return result
return wrapper
@timer
def slow_function():
time.sleep(1)
return "done"
slow_function() # slow_function took 1.001s
The @timer syntax is equivalent to slow_function = timer(slow_function).
Practical Examples
Validation function
def validate_email(email: str) -> tuple[bool, str]:
if not email:
return False, "Email is required"
if "@" not in email:
return False, "Email must contain @"
if "." not in email.split("@")[1]:
return False, "Invalid domain"
return True, ""
valid, error = validate_email("alice@example.com")
print(valid) # True
valid, error = validate_email("not-an-email")
print(error) # Email must contain @
Retry logic
import time
import random
def retry(max_attempts=3, delay=1):
def decorator(func):
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts:
raise
print(f"Attempt {attempt} failed: {e}. Retrying...")
time.sleep(delay)
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5)
def unreliable_api_call():
if random.random() < 0.7:
raise ConnectionError("Server unavailable")
return {"status": "ok"}
What’s Next
Now that you can write functions, you’re ready to work with Python’s most versatile data structure: Lists. Lists and functions together form the backbone of most Python programs.