Python Functions Complete Guide
Learn Python functions from the ground up: defining and calling functions, parameters, return values, scope, type hints, and recursion — with examples and best practices.
Modular
Reusable code
Parameters
Flexible inputs
Return values
Single or multiple
Recursion
Base case & patterns
Introduction to Functions
Functions are reusable blocks of code that perform specific tasks. They help in organizing code, reducing repetition, and improving maintainability.
Benefits of Functions
- Code reusability
- Modular programming
- Easier debugging
- Better organization
Key idea
Functions follow the DRY (Don't Repeat Yourself) principle. Use def to define them, parameters for inputs, and return for outputs.
Defining and Calling Functions
Basic function definition
# Simple function without parameters
def greet():
print("Hello, World!")
# Call the function
greet() # Output: Hello, World!
# Function with one parameter
def greet_person(name):
print(f"Hello, {name}!")
greet_person("Alice") # Output: Hello, Alice!
# Function with multiple parameters
def add_numbers(a, b):
result = a + b
print(f"{a} + {b} = {result}")
add_numbers(5, 3) # Output: 5 + 3 = 8
Function with return value
def multiply(x, y):
return x * y
result = multiply(4, 5)
print(result) # Output: 20
# Functions can have multiple return statements
def check_even(number):
if number % 2 == 0:
return True
else:
return False
print(check_even(4)) # Output: True
print(check_even(7)) # Output: False
pass placeholder
def function_to_implement_later():
pass # Placeholder, does nothing
# This allows the code to run without errors
function_to_implement_later()
Function Parameters and Arguments
Positional arguments
def describe_person(name, age, city):
print(f"{name} is {age} years old and lives in {city}")
# Arguments must be in correct order
describe_person("Alice", 30, "New York")
# Output: Alice is 30 years old and lives in New York
Keyword arguments
def describe_person(name, age, city):
print(f"{name} is {age} years old and lives in {city}")
# Order doesn't matter with keyword arguments
describe_person(city="Boston", name="Bob", age=25)
# Output: Bob is 25 years old and lives in Boston
Default parameters
def greet(name, greeting="Hello"):
print(f"{greeting}, {name}!")
greet("Alice") # Output: Hello, Alice!
greet("Bob", "Hi") # Output: Hi, Bob!
greet("Charlie", greeting="Good morning") # Output: Good morning, Charlie!
# Default parameters must come after non-default
def example(a, b=10, c=20): # Correct
pass
# def example(a=10, b): # Incorrect - syntax error
# pass
*args and **kwargs
# *args - variable number of positional arguments (tuple)
def sum_all(*numbers):
total = 0
for num in numbers:
total += num
return total
print(sum_all(1, 2, 3)) # Output: 6
print(sum_all(5, 10, 15, 20)) # Output: 50
print(sum_all()) # Output: 0
# **kwargs - variable number of keyword arguments (dictionary)
def print_info(**info):
for key, value in info.items():
print(f"{key}: {value}")
print_info(name="Alice", age=30, city="New York")
# Output:
# name: Alice
# age: 30
# city: New York
# Combining *args and **kwargs
def combined_function(*args, **kwargs):
print(f"Positional arguments: {args}")
print(f"Keyword arguments: {kwargs}")
combined_function(1, 2, 3, name="Alice", age=30)
# Output:
# Positional arguments: (1, 2, 3)
# Keyword arguments: {'name': 'Alice', 'age': 30}
Return Values
Single return value
def square(x):
return x ** 2
result = square(5)
print(result) # Output: 25
Multiple return values
def get_min_max(numbers):
return min(numbers), max(numbers)
minimum, maximum = get_min_max([1, 5, 3, 9, 2])
print(f"Min: {minimum}, Max: {maximum}") # Output: Min: 1, Max: 9
# Functions return None if no return statement
def no_return():
pass
result = no_return()
print(result) # Output: None
Early returns
def check_access(age, has_id):
if age < 18:
return "Too young"
if not has_id:
return "ID required"
return "Access granted"
print(check_access(16, True)) # Output: Too young
print(check_access(20, False)) # Output: ID required
print(check_access(25, True)) # Output: Access granted
Scope and Lifetime of Variables
Local vs global
# Global variable
global_var = "I am global"
def my_function():
# Local variable
local_var = "I am local"
print(global_var) # Can access global
print(local_var) # Can access local
my_function()
# print(local_var) # Error! local_var not accessible outside
# Modifying global variables
counter = 0
def increment():
global counter # Declare that we're using global variable
counter += 1
increment()
increment()
print(counter) # Output: 2
Nested functions and nonlocal
nonlocal exampledef outer_function():
outer_var = "I'm in outer"
def inner_function():
nonlocal outer_var # Modify variable from outer scope
outer_var = "Modified by inner"
print(outer_var)
inner_function()
print(outer_var)
outer_function()
# Output:
# Modified by inner
# Modified by inner
# Without nonlocal inside inner, assigning would create a new local name;
# reading still sees the enclosing variable.
def example():
x = 10
def inner():
print(x) # Reads from enclosing scope
inner()
Function annotations (type hints)
Adding type hints (Python 3.5+) documents expected types. They are optional at runtime unless you use a type checker.
# Adding type hints (Python 3.5+)
def greet(name: str) -> str:
return f"Hello, {name}!"
def divide(dividend: float, divisor: float) -> float:
if divisor == 0:
raise ValueError("Cannot divide by zero")
return dividend / divisor
# Annotations are stored in __annotations__
print(greet.__annotations__) # {'name': <class 'str'>, 'return': <class 'str'>}
# Complex annotations (Python 3.9+ built-in generics)
def process_data(
data: list[int],
threshold: float = 0.5,
verbose: bool = False
) -> dict[str, float]:
"""Process data and return statistics"""
result = {
"mean": sum(data) / len(data),
"max": max(data),
"min": min(data)
}
return result
Recursion Basics
Recursion is a technique where a function calls itself to solve a problem by breaking it down into smaller, similar subproblems.
Basic recursion structure
def recursive_function(parameters):
# Base case — condition to stop recursion
if base_condition:
return base_value
# Recursive case — function calls itself
return recursive_function(modified_parameters)
Simple examples
# Countdown using recursion
def countdown(n):
if n <= 0: # Base case
print("Blast off!")
return
print(n)
countdown(n - 1) # Recursive call
countdown(5)
# Output:
# 5
# 4
# 3
# 2
# 1
# Blast off!
# Print numbers from 1 to n using recursion
def print_numbers(n):
if n == 0:
return
print_numbers(n - 1)
print(n)
print_numbers(5)
# Output: 1 2 3 4 5 (each on new line)
n because of overlapping subproblems.Tail recursion
In Python, tail calls are not optimized; loops are usually better when depth grows large.
# Tail recursion - the recursive call is the last operation
def factorial_tail(n, accumulator=1):
if n <= 1:
return accumulator
return factorial_tail(n - 1, n * accumulator)
print(factorial_tail(5)) # Output: 120
# Sum with tail recursion
def sum_tail(numbers, accumulator=0):
if not numbers:
return accumulator
return sum_tail(numbers[1:], accumulator + numbers[0])
print(sum_tail([1, 2, 3, 4, 5])) # Output: 15
Mutual recursion
def is_even(n):
if n == 0:
return True
return is_odd(n - 1)
def is_odd(n):
if n == 0:
return False
return is_even(n - 1)
print(is_even(4)) # Output: True
print(is_even(5)) # Output: False
print(is_odd(4)) # Output: False
print(is_odd(5)) # Output: True
Tree recursion
def generate_binary_strings(n, prefix=""):
if n == 0:
print(prefix)
return
generate_binary_strings(n - 1, prefix + "0")
generate_binary_strings(n - 1, prefix + "1")
print("Binary strings of length 3:")
generate_binary_strings(3)
# Output:
# 000
# 001
# 010
# 011
# 100
# 101
# 110
# 111
When to use recursion
Good for
- Tree-like structures (file systems, HTML DOM)
- Divide-and-conquer (merge sort, quicksort)
- Backtracking (mazes, N-Queens)
- Graph traversal (DFS)
- Problems with natural recursive definitions (factorial, Fibonacci)
Avoid for
- Simple loops (prefer iteration)
- Very deep recursion (stack limits / recursion depth)
- Tight performance loops (call overhead)
- Problems without a clear, reachable base case
Memoization
Caching results avoids redundant work in recursive algorithms with overlapping subproblems.
memo = {}
def fib_memo(n):
if n in memo:
return memo[n]
if n <= 1:
memo[n] = n
return n
memo[n] = fib_memo(n - 1) + fib_memo(n - 2)
return memo[n]
print(fib_memo(30))
RecursionError. Use iteration, functools.lru_cache, or redesign the algorithm if needed.
Key Takeaways
Functions
- Functions promote code reuse and modularity
- Parameters can be positional, keyword, default, or variable-length (
*args,**kwargs) - Return values can be single, multiple (as tuples), or
None - Variables have scope — local, global, and
nonlocalfor enclosing scopes - Lambda functions are anonymous single-expression functions (see the Lambda tutorial)
Recursion
- Every recursive function needs a base case
- Recursion breaks problems into smaller, similar subproblems
- Recursion adds overhead (calls, stack memory)
- Tail recursion is a stylistic pattern; Python does not eliminate tail calls
- Memoization can greatly speed up recursive solutions with overlapping subproblems
Best practices
- Use iteration for simple repetition
- Use recursion when the problem has a natural recursive or tree-shaped structure
- Always ensure the base case is reachable
- Mind recursion depth for large inputs
- Use memoization for overlapping subproblems (for example naive Fibonacci)