If you have been writing Python for any length of time, you have already been using functions — print(), len(), range(). Functions are the fundamental building blocks of reusable, maintainable code. They allow you to encapsulate a piece of logic, give it a name, and call it whenever you need it, from anywhere in your program. This follows the DRY principle (Don’t Repeat Yourself): write the logic once, and reuse it as many times as necessary. Without functions, you would be copying and pasting the same blocks of code throughout your project, creating a maintenance nightmare where a single bug fix requires changes in dozens of places.
In this tutorial, we will explore Python functions thoroughly — from basic definitions to advanced patterns like *args, **kwargs, type hints, and first-class function behavior. By the end, you will write functions the way a seasoned Python developer does.
You define a function in Python using the def keyword, followed by the function name, parentheses for parameters, and a colon. The indented block beneath is the function body.
def function_name(parameters):
"""Optional docstring describing the function."""
# function body
statements
To call a function, use its name followed by parentheses, passing in any required arguments.
def say_hello(name):
print(f"Hello, {name}!")
say_hello("Folau") # Output: Hello, Folau!
Function names should be lowercase, with words separated by underscores (snake_case). Choose descriptive names that clearly communicate what the function does: calculate_tax() is far better than ct().
These two terms are often used interchangeably, but they have distinct meanings. A parameter is the variable listed in the function definition. An argument is the actual value you pass when calling the function.
def greet(name): # 'name' is a parameter
print(f"Hi, {name}!")
greet("Folau") # "Folau" is an argument
By default, a function must be called with the exact number of arguments it expects. If your function defines two parameters, you must supply exactly two arguments — no more, no less — unless you use default parameters or variable-length arguments (covered below).
A function can send a result back to the caller using the return statement. Once return executes, the function exits immediately.
def add(a, b):
return a + b
result = add(3, 5)
print(result) # 8
Returning multiple values is straightforward in Python — just separate them with commas. Under the hood, Python packs them into a tuple.
def get_user_info():
name = "Folau"
age = 30
email = "folau@example.com"
return name, age, email
# Unpack the returned tuple
user_name, user_age, user_email = get_user_info()
print(f"{user_name}, {user_age}, {user_email}")
# Output: Folau, 30, folau@example.com
You can assign default values to parameters. If the caller does not provide an argument for that parameter, the default is used. Parameters with defaults must come after parameters without defaults.
def greet(name, greeting="Hello"):
print(f"{greeting}, {name}!")
greet("Folau") # Hello, Folau!
greet("Folau", "Good morning") # Good morning, Folau!
This is useful for providing sensible defaults while still allowing customization when needed.
When calling a function, you can pass arguments using the key=value syntax. This makes calls more readable and allows you to pass arguments in any order.
def create_profile(name, age, city):
print(f"{name}, age {age}, from {city}")
# Using keyword arguments - order doesn't matter
create_profile(city="Salt Lake City", name="Folau", age=30)
# Output: Folau, age 30, from Salt Lake City
When you don’t know in advance how many arguments will be passed to your function, prefix a parameter with *. The function will receive them as a tuple, and you can iterate over them or index into them.
def flexible_sum(*numbers):
"""Return the sum of any number of numeric arguments."""
total = 0
for num in numbers:
total += num
return total
print(flexible_sum(1, 2, 3)) # 6
print(flexible_sum(10, 20, 30, 40)) # 100
print(flexible_sum(5)) # 5
You can combine *args with regular parameters. The regular parameters come first.
def print_profile(role, *skills):
print(f"Role: {role}")
print(f"Skills: {', '.join(skills)}")
print_profile("Developer", "Python", "Java", "SQL")
# Role: Developer
# Skills: Python, Java, SQL
If you don’t know how many keyword arguments will be passed, prefix a parameter with **. The function will receive them as a dictionary.
def build_config(**settings):
"""Build a configuration dictionary from keyword arguments."""
config = {}
for key, value in settings.items():
config[key] = value
print(f" {key} = {value}")
return config
config = build_config(
host="localhost",
port=5432,
database="mydb",
debug=True
)
# Output:
# host = localhost
# port = 5432
# database = mydb
# debug = True
You can combine all parameter types in a single function. The order must be: regular parameters, *args, keyword-only parameters, **kwargs.
def make_request(url, *headers, timeout=30, **params):
print(f"URL: {url}")
print(f"Headers: {headers}")
print(f"Timeout: {timeout}")
print(f"Params: {params}")
make_request("https://api.example.com", "Auth: Bearer token", timeout=10, page=1, limit=20)
A docstring is a string literal placed as the first statement in a function body. It documents what the function does, its parameters, and its return value. You can access it at runtime via the __doc__ attribute or the help() function.
def calculate_area(length, width):
"""
Calculate the area of a rectangle.
Args:
length (float): The length of the rectangle.
width (float): The width of the rectangle.
Returns:
float: The area of the rectangle.
"""
return length * width
# Access the docstring
print(calculate_area.__doc__)
help(calculate_area)
Good docstrings save hours of reading code. They are especially valuable on teams, where someone else (or future-you) needs to understand what a function does without reading through the implementation line by line.
Variables defined inside a function are local — they exist only within that function. Variables defined outside all functions are global — they are accessible throughout the module. Understanding scope prevents subtle bugs.
counter = 0 # global variable
def increment():
counter = 1 # this creates a LOCAL variable, does NOT modify the global
print(f"Inside function: counter = {counter}")
increment()
print(f"Outside function: counter = {counter}")
# Inside function: counter = 1
# Outside function: counter = 0
To modify a global variable from within a function, use the global keyword — but use it sparingly. Global mutable state makes programs harder to reason about and debug.
counter = 0
def increment():
global counter
counter += 1
increment()
increment()
print(counter) # 2
The LEGB Rule: Python resolves variable names by searching four scopes in order: Local (inside the current function), Enclosing (inside any enclosing functions, for nested functions), Global (module-level), and Built-in (Python’s built-in names like print, len). The first match wins.
In Python, functions are first-class objects. This means you can assign them to variables, store them in data structures, pass them as arguments to other functions, and return them from functions — just like any other value.
# Assign a function to a variable
def square(x):
return x * x
operation = square
print(operation(5)) # 25
# Pass a function as an argument
def apply_operation(func, value):
return func(value)
print(apply_operation(square, 4)) # 16
# Return a function from a function
def make_multiplier(factor):
def multiplier(x):
return x * factor
return multiplier
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(10)) # 20
print(triple(10)) # 30
This capability is what makes patterns like decorators, callbacks, and higher-order functions possible in Python.
A lambda is a small anonymous function defined with the lambda keyword. It can take any number of arguments but can only contain a single expression. Lambdas are most useful when you need a short, throwaway function — typically as an argument to higher-order functions like map(), filter(), or sorted().
# Simple lambda examples double = lambda x: x * 2 multiply = lambda a, b: a * b print(double(5)) # 10 print(multiply(3, 4)) # 12
Where lambdas really shine is inline usage with built-in functions.
# Filter even numbers numbers = [1, 2, 3, 4, 5, 6, 7, 8] evens = list(filter(lambda x: x % 2 == 0, numbers)) print(evens) # [2, 4, 6, 8] # Sort a list of tuples by the second element pairs = [(1, "banana"), (3, "apple"), (2, "cherry")] sorted_pairs = sorted(pairs, key=lambda pair: pair[1]) print(sorted_pairs) # [(3, 'apple'), (1, 'banana'), (2, 'cherry')]
For anything more than a simple expression, use a regular def function instead. Lambdas should not contain complex logic — readability always comes first.
A recursive function is a function that calls itself. Every recursive function needs a base case (the condition that stops the recursion) to avoid infinite loops. Recursion is a natural fit for problems that can be broken down into smaller, identical subproblems.
def factorial(n):
"""
Calculate the factorial of a non-negative integer.
factorial(5) = 5 * 4 * 3 * 2 * 1 = 120
"""
if n <= 1: # base case
return 1
return n * factorial(n - 1) # recursive case
print(factorial(5)) # 120
print(factorial(0)) # 1
print(factorial(10)) # 3628800
Be mindful that Python has a default recursion limit of 1000 calls. For deeply recursive problems, consider iterative solutions or increase the limit with sys.setrecursionlimit().
Type hints let you annotate function parameters and return types. They do not enforce types at runtime — Python remains dynamically typed — but they serve as documentation and enable tools like mypy and IDE auto-completion to catch bugs before you run the code.
def calculate_circle_area(radius: float) -> float:
"""Calculate the area of a circle given its radius."""
import math
return math.pi * radius ** 2
def format_name(first: str, last: str, uppercase: bool = False) -> str:
"""Format a full name, optionally in uppercase."""
full_name = f"{first} {last}"
return full_name.upper() if uppercase else full_name
print(calculate_circle_area(5.0)) # 78.53981633974483
print(format_name("Folau", "Kaveinga")) # Folau Kaveinga
print(format_name("Folau", "Kaveinga", True)) # FOLAU KAVEINGA
For more complex types, use the typing module.
from typing import List, Dict, Optional, Tuple
def find_max(numbers: List[int]) -> Optional[int]:
"""Return the maximum value, or None if the list is empty."""
if not numbers:
return None
return max(numbers)
def get_user(user_id: int) -> Dict[str, str]:
"""Fetch a user record by ID."""
return {"id": str(user_id), "name": "Folau"}
Function definitions cannot be empty. If you need a placeholder function (for example, during early development or when defining an interface), use pass to avoid a syntax error.
def not_implemented_yet():
pass # TODO: implement this later
Calculate area of different shapes
import math
def calculate_area(shape: str, **dimensions) -> float:
"""
Calculate the area of a shape.
Supported shapes: rectangle, circle, triangle.
Pass dimensions as keyword arguments.
"""
shape = shape.lower()
if shape == "rectangle":
return dimensions["length"] * dimensions["width"]
elif shape == "circle":
return math.pi * dimensions["radius"] ** 2
elif shape == "triangle":
return 0.5 * dimensions["base"] * dimensions["height"]
else:
raise ValueError(f"Unsupported shape: {shape}")
print(calculate_area("rectangle", length=10, width=5)) # 50
print(calculate_area("circle", radius=7)) # 153.938...
print(calculate_area("triangle", base=6, height=4)) # 12.0
Validate email format
import re
def is_valid_email(email: str) -> bool:
"""
Validate an email address against a basic pattern.
Returns True if the email format is valid, False otherwise.
"""
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return bool(re.match(pattern, email))
print(is_valid_email("folau@example.com")) # True
print(is_valid_email("invalid-email")) # False
print(is_valid_email("user@domain.co.uk")) # True
print(is_valid_email("@missing-local.com")) # False
Greeting with optional name (default parameters)
def greet(name: str = "World", greeting: str = "Hello", punctuation: str = "!") -> str:
"""Build a customizable greeting string."""
return f"{greeting}, {name}{punctuation}"
print(greet()) # Hello, World!
print(greet("Folau")) # Hello, Folau!
print(greet("Folau", "Good morning")) # Good morning, Folau!
print(greet("Folau", "Hey", "!!!")) # Hey, Folau!!!
Using **kwargs for building configuration
def build_db_config(db_type: str = "postgresql", **overrides) -> dict:
"""
Build a database configuration with sensible defaults.
Any keyword argument overrides the default.
"""
defaults = {
"host": "localhost",
"port": 5432,
"database": "myapp",
"user": "admin",
"password": "",
"pool_size": 5,
}
defaults.update(overrides)
defaults["db_type"] = db_type
return defaults
# Use defaults
config = build_db_config()
print(config)
# {'host': 'localhost', 'port': 5432, 'database': 'myapp', ...}
# Override specific settings
prod_config = build_db_config(
db_type="postgresql",
host="db.production.com",
password="s3cur3p@ss",
pool_size=20
)
print(prod_config["host"]) # db.production.com
print(prod_config["pool_size"]) # 20
A module is simply a .py file containing Python definitions and statements. For example, a file named user.py is a module named user. Modules help you organize code into logical, reusable units. Instead of copying function definitions across files, you import them.
import user user.say_hello()
You can rename a module on import with as, or import specific items directly.
# Import with alias import user as u u.say_hello() # Import a specific function from user import say_hello say_hello() # Import everything (use sparingly - pollutes namespace) from user import *
Python has an extensive standard library. You can browse the full list of Python standard modules to see what is available out of the box.
1. Mutable default arguments (the classic Python gotcha)
Never use a mutable object (like a list or dictionary) as a default parameter value. The default is created once when the function is defined, not each time the function is called. This leads to shared state across calls.
# BAD - the same list is shared across all 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'] -- unexpected!
# GOOD - use None and create a new list each call
def add_item_good(item, items=None):
if items is None:
items = []
items.append(item)
return items
print(add_item_good("a")) # ['a']
print(add_item_good("b")) # ['b'] -- correct
2. Forgetting to return (returns None)
If a function does not explicitly return a value, it returns None. This is a common source of bugs, especially when you intend the function to produce a result.
def calculate_total(prices):
total = sum(prices)
# Oops - forgot to return total!
result = calculate_total([10, 20, 30])
print(result) # None -- not what we wanted
# Fix: add return statement
def calculate_total(prices):
total = sum(prices)
return total
3. Variable shadowing
Creating a local variable with the same name as a global variable (or a built-in) hides the outer variable. This can cause confusing bugs.
values = [1, 2, 3]
def process():
# This creates a NEW local variable named 'values', shadowing the global
values = [10, 20, 30]
print(f"Inside: {values}") # [10, 20, 30]
process()
print(f"Outside: {values}") # [1, 2, 3] -- global is unchanged
# Even worse: shadowing built-ins
list = [1, 2, 3] # Don't do this! Shadows the built-in list() function
# list("abc") # TypeError: 'list' object is not callable
1. Keep functions small and focused (Single Responsibility): Each function should do one thing and do it well. If a function is doing multiple unrelated tasks, break it up. A good rule of thumb: if you cannot describe what the function does in a single sentence without using "and," it is doing too much.
2. Use descriptive names: Function names should clearly communicate intent. Prefer calculate_monthly_payment() over calc(), and is_valid_email() over check(). Your code should read like well-written prose.
3. Add docstrings: Every public function should have a docstring explaining what it does, what parameters it accepts, and what it returns. Follow a consistent style like Google, NumPy, or Sphinx format across your project.
4. Use type hints for clarity: Type hints make your function signatures self-documenting. They help your IDE provide better auto-completion and enable static analysis tools like mypy to catch type errors before runtime.
5. Avoid global state: Pass data into functions through parameters and get results through return values. Functions that rely on or modify global state are harder to test, debug, and reuse.
6. Handle edge cases: Think about what happens with empty inputs, negative numbers, None values, or unexpected types. Raise clear exceptions with informative messages when inputs are invalid.
def and are the primary mechanism for code reuse in Python.*args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dictionary.None as the default instead.