Functions

May 18, 2026
#python #functions #beginner

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 def keyword
  • A name (snake_case by convention)
  • Parameters in parentheses
  • A colon, then an indented body
  • An optional return statement (returns None if 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!

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.