Python Functions ā Complete Guide
Functions are the building blocks of Python programs. They let you organize code into reusable pieces, avoid repetition, and write programs that are easier to read, test, and maintain.
Learning Objectives
By the end of this tutorial, you will be able to:
- Define functions using
defwith proper naming conventions - Use parameters, default values,
*args, and**kwargs - Return values including multiple values via tuples
- Understand variable scope and the LEGB rule
- Write closures and understand late binding
- Create lambda functions and higher-order functions
- Implement basic recursion with proper base cases
- Write effective docstrings and type hints
- Avoid common function-related mistakes
Defining Functions
The def Keyword
def greet(name):
"""Return a greeting string."""
return f"Hello, {name}!"
print(greet("Alice")) # Hello, Alice!
Naming Conventions (PEP 8)
| Convention | Example | Description |
|---|---|---|
| lowercase + underscores | calculate_average | Standard snake_case |
| Verbs first | get_user, compute_total | Action verb prefix |
| No built-in names | Don't use list, dict, sum | Avoid shadowing |
Docstrings
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 value as a float.
"""
return weight_kg / (height_m ** 2)
Parameters and Arguments
Parameters are variable names in the function definition. Arguments are actual values passed when calling.
Positional and Keyword Arguments
def power(base, exponent):
return base ** exponent
print(power(2, 3)) # 8 (positional)
print(power(exponent=3, base=2)) # 8 (keyword)
Default Parameter Values
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
print(greet("Alice")) # Hello, Alice!
print(greet("Bob", "Good morning")) # Good morning, Bob!
ā ļø Mutable Default Argument Gotcha: Never use mutable objects (lists, dicts) as default values. They are created once and shared across calls.
# WRONG ā shared list across calls
def add_item(item, items=[]):
items.append(item)
return items
print(add_item("apple")) # ['apple']
print(add_item("banana")) # ['apple', 'banana'] ā unexpected!
# CORRECT ā new list each time
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
print(add_item("apple")) # ['apple']
print(add_item("banana")) # ['banana'] ā correct!
*args and **kwargs
def total(*numbers):
"""Sum any number of arguments."""
return sum(numbers)
print(total(1, 2, 3)) # 6
print(total(1, 2, 3, 4, 5)) # 15
def build_profile(**info):
"""Build a profile dictionary."""
return info
profile = build_profile(name="Alice", age=30, city="NYC")
print(profile) # {'name': 'Alice', 'age': 30, 'city': 'NYC'}
Parameter Ordering Rules
1. Regular parameters
2. Default parameters
3. *args
4. Keyword-only parameters (after *)
5. **kwargs
def func(positional, keyword_default, *args, key_only, **kwargs):
print(f"positional: {positional}")
print(f"*args: {args}")
print(f"key_only: {key_only}")
print(f"**kwargs: {kwargs}")
func(1, 2, 3, 4, 5, key_only="mandatory", extra="optional")
Positional-Only and Keyword-Only (Python 3.8+)
def pos_only(a, b, /, c=10):
return a + b + c
print(pos_only(1, 2)) # 3
# pos_only(a=1, b=2) # TypeError
def kw_only(*, x, y=0):
return x + y
print(kw_only(x=5)) # 5
# kw_only(5) # TypeError
Return Values
Returning Multiple Values (Tuples)
def min_max(numbers):
return min(numbers), max(numbers)
low, high = min_max([3, 1, 7, 2, 9])
print(f"Min: {low}, Max: {high}") # Min: 1, Max: 9
Implicit None Return
def say_hello(name):
print(f"Hello, {name}!")
print(say_hello("Alice")) # None
Early Returns
def process(value):
if value is None:
return None
if value <= 0:
return 0
return value * 2
Variable Scope
LEGB Rule
Python searches for variables in this order:
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Built-in (B) ā
ā āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā ā
ā ā Global (G) ā ā
ā ā āāāāāāāāāāāāāāāāāāāāāāā ā ā
ā ā ā Enclosing (E) ā ā ā
ā ā ā āāāāāāāāāāāāāāā ā ā ā
ā ā ā ā Local (L) ā ā ā ā
ā ā ā āāāāāāāāāāāāāāā ā ā ā
ā ā āāāāāāāāāāāāāāāāāāāāāāā ā ā
ā āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Local ā Enclosing ā Global ā Built-in
Local and Global Scope
x = "global"
def my_function():
x = "local"
print(f"Inside: {x}")
my_function() # Inside: local
print(f"Outside: {x}") # Outside: global
The global and nonlocal Keywords
counter = 0
def increment():
global counter
counter += 1
increment()
increment()
print(counter) # 2
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
counter = make_counter()
print(counter()) # 1
print(counter()) # 2
First-Class Functions
In Python, functions are first-class objects ā they can be assigned to variables, passed as arguments, and returned from other functions.
def add(a, b):
return a + b
plus = add
print(plus(3, 4)) # 7
def apply(func, value):
return func(value)
def square(x):
return x ** 2
print(apply(square, 5)) # 25
print(apply(len, "hello")) # 5
def make_multiplier(factor):
def multiplier(x):
return x * factor
return multiplier
double = make_multiplier(2)
print(double(5)) # 10
Closures
A closure is a function that remembers variables from its enclosing scope.
def make_greeting(prefix):
def greet(name):
return f"{prefix}, {name}!"
return greet
hello = make_greeting("Hello")
goodbye = make_greeting("Goodbye")
print(hello("Alice")) # Hello, Alice!
print(goodbye("Bob")) # Goodbye, Bob!
Late Binding Gotcha
Closures capture variables by reference, not by value:
# WRONG ā classic gotcha
functions = []
for i in range(5):
functions.append(lambda: i)
print([f() for f in functions]) # [4, 4, 4, 4, 4]
# CORRECT ā capture the current value
functions = []
for i in range(5):
functions.append(lambda i=i: i)
print([f() for f in functions]) # [0, 1, 2, 3, 4]
Practical Closure Uses
def make_counter(start=0):
count = [start]
def counter():
count[0] += 1
return count[0]
return counter
c = make_counter()
print(c()) # 1
print(c()) # 2
def make_cache():
cache = {}
def cached_func(key, value=None):
if value is not None:
cache[key] = value
return cache.get(key)
return cached_func
settings = make_cache()
settings("theme", "dark")
print(settings("theme")) # dark
Lambda Functions
Lambda functions are anonymous, single-expression functions:
square = lambda x: x ** 2
print(square(5)) # 25
When to Use Lambdas
Best as arguments to higher-order functions:
students = [("Alice", 90), ("Bob", 80), ("Charlie", 95)]
students.sort(key=lambda student: student[1])
print(students) # [('Bob', 80), ('Alice', 90), ('Charlie', 95)]
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9]
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens) # [2, 4, 6, 8]
Limitations
- Only a single expression (no statements)
- No docstrings or type hints
- Harder to debug
Higher-Order Functions
A higher-order function takes a function as argument or returns a function.
Built-in Higher-Order Functions
numbers = [1, 2, 3, 4, 5]
doubled = list(map(lambda x: x * 2, numbers))
print(doubled) # [2, 4, 6, 8, 10]
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens) # [2, 4]
words = ["banana", "apple", "cherry"]
print(sorted(words, key=len)) # ['apple', 'banana', 'cherry']
Decorators ā Higher-Order Functions in Practice
A decorator wraps a function to add behavior:
import time
def timer(func):
"""Measure execution time of a function."""
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start:.4f} seconds")
return result
return wrapper
@timer
def slow_function():
time.sleep(1)
return "Done"
result = slow_function()
# slow_function took 1.0012 seconds
Recursion
A function that calls itself is recursive. It requires a base case to stop.
Base Case and Recursive Case
def factorial(n):
if n <= 1: # Base case
return 1
return n * factorial(n - 1) # Recursive case
print(factorial(5)) # 120
factorial(5)
ā 5 * factorial(4)
ā 4 * factorial(3)
ā 3 * factorial(2)
ā 2 * factorial(1)
ā 1 (base case)
ā 2
ā 6
ā 24
ā 120
Fibonacci Sequence
def fibonacci(n):
if n <= 0:
return 0
if n == 1:
return 1
return fibonacci(n - 1) + fibonacci(n - 2)
for i in range(10):
print(fibonacci(i), end=" ")
# 0 1 1 2 3 5 8 13 21 34
Recursion Limit
import sys
print(sys.getrecursionlimit()) # 1000 (default)
When to Use Recursion vs Iteration
| Use Recursion When | Use Iteration When |
|---|---|
| Problem has recursive structure (trees, graphs) | Simple loops over sequences |
| Depth is guaranteed small | Depth could be large |
| Code clarity matters most | Performance is critical |
Docstrings and Type Hints
Writing Good Docstrings
def calculate_statistics(data):
"""Calculate basic statistics for a dataset.
Args:
data: A list of numerical values. Must not be empty.
Returns:
A dictionary with keys 'mean', 'median', and 'stdev'.
Raises:
ValueError: If data is empty.
"""
if not data:
raise ValueError("Data must not be empty")
n = len(data)
mean = sum(data) / n
sorted_data = sorted(data)
median = sorted_data[n // 2]
variance = sum((x - mean) ** 2 for x in data) / n
stdev = variance ** 0.5
return {"mean": mean, "median": median, "stdev": stdev}
Type Annotations
def greet(name: str) -> str:
return f"Hello, {name}!"
def add_numbers(a: float, b: float) -> float:
return a + b
from typing import Optional
def find_item(items: list[str], target: str) -> Optional[str]:
for item in items:
if item == target:
return item
return None
Common Mistakes
1. Mutable Default Argument
# WRONG
def append_to(item, target=[]):
target.append(item)
return target
print(append_to(1)) # [1]
print(append_to(2)) # [1, 2] ā unexpected!
# CORRECT
def append_to(item, target=None):
if target is None:
target = []
target.append(item)
return target
2. Modifying Global Without global
x = 10
def change_x():
x = 20 # Creates local variable, doesn't modify global
change_x()
print(x) # Still 10!
3. Forgetting to Return
def calculate_sum(a, b):
total = a + b
# Missing return!
print(calculate_sum(3, 4)) # None ā not 7!
4. Late Binding in Closures
# WRONG
functions = [lambda: i for i in range(5)]
print([f() for f in functions]) # [4, 4, 4, 4, 4]
# CORRECT
functions = [lambda i=i: i for i in range(5)]
print([f() for f in functions]) # [0, 1, 2, 3, 4]
5. Too Many Parameters
# BAD
def create_user(name, age, email, phone, address, city, state, zip_code, country):
pass
# BETTER
def create_user(**kwargs):
required = {"name", "email"}
if not required.issubset(kwargs.keys()):
raise ValueError(f"Missing required: {required - kwargs.keys()}")
return kwargs
Practice Exercises
Exercise 1: Function Composition
Write a function compose that takes two functions and returns a new function that applies them in sequence.
def compose(f, g):
"""Return a function that applies g then f."""
def composed(x):
return f(g(x))
return composed
double = lambda x: x * 2
add_one = lambda x: x + 1
double_then_add = compose(add_one, double)
print(double_then_add(3)) # 7 (3 * 2 + 1)
add_then_double = compose(double, add_one)
print(add_then_double(3)) # 8 ((3 + 1) * 2)
Exercise 2: Memoization Decorator
Write a decorator memoize that caches function results.
import functools
def memoize(func):
"""Cache results of function calls."""
cache = {}
@functools.wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
wrapper.cache = cache
return wrapper
@memoize
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(30)) # 832040
print(fibonacci.cache) # {0: 0, 1: 1, 2: 1, ...}
Exercise 3: Curry Function
Write a function curry that transforms a multi-argument function into a chain of single-argument calls.
import inspect
def curry(func):
"""Curry a function ā convert f(a, b, c) to f(a)(b)(c)."""
params = inspect.signature(func).parameters
def curried(*args):
if len(args) >= len(params):
return func(*args)
def partial(*more_args):
return curried(*(args + more_args))
return partial
return curried
@curry
def add(a, b, c):
return a + b + c
print(add(1)(2)(3)) # 6
print(add(1, 2)(3)) # 6
print(add(1)(2, 3)) # 6
Key Takeaways
- Functions are reusable blocks of code that encapsulate logic
- Parameters define what a function accepts; arguments are actual values passed
- Default arguments make parameters optional; always use
Nonefor mutable defaults *argsand**kwargshandle variable numbers of arguments flexibly- Return values can be any type; multiple values are returned as tuples
- Scope follows the LEGB rule: Local ā Enclosing ā Global ā Built-in
- Closures remember variables from enclosing scope ā watch out for late binding
- Functions are first-class objects ā assign them, pass them, return them
- Decorators are higher-order functions for adding cross-cutting concerns
- Recursion needs a base case; Python doesn't optimize tail calls
- Type hints and docstrings make functions self-documenting
What's Next
ā Lambda Functions and Functional Programming ā Decorators Deep Dive ā Error Handling with try/except ā Object-Oriented Programming