In the previous tutorial, we learned about control flow: if, for, while, and match/case. Now let’s learn about functions — reusable blocks of code.

Functions are the foundation of clean code. They let you write a piece of logic once and use it many times. By the end of this tutorial, you will know how to define functions, use different parameter types, write lambda functions, and understand closures.

Defining a Function

Use the def keyword to create a function:

def greet(name: str) -> str:
    """Return a greeting message for the given name."""
    return f"Hello, {name}!"

message = greet("Alex")
print(message)  # Output: Hello, Alex!

Let me break this down:

  • def — keyword to define a function
  • greet — the function name
  • (name: str) — the parameter with a type hint
  • -> str — return type hint (the function returns a string)
  • """...""" — docstring (describes what the function does)
  • return — sends a value back to the caller

Type hints (str, -> str) are optional. Python ignores them at runtime. But they make your code easier to understand, and tools like VS Code use them for autocomplete.

Parameters and Return Values

Default Parameters

Give a parameter a default value with =:

def greet(name: str = "World") -> str:
    return f"Hello, {name}!"

print(greet())        # Hello, World!
print(greet("Alex"))  # Hello, Alex!

If the caller does not pass an argument, the default value is used.

Named Arguments

You can pass arguments by name. This is useful when a function has many parameters:

def create_user(name: str, age: int, city: str) -> dict:
    return {"name": name, "age": age, "city": city}

# Positional arguments
user1 = create_user("Alex", 25, "Berlin")

# Named arguments — order does not matter
user2 = create_user(city="Berlin", name="Alex", age=25)

Both calls produce the same result. Named arguments make the code more readable.

Multiple Return Values

Python functions can return multiple values using tuples:

def divide(a: int, b: int) -> tuple[int, int]:
    quotient = a // b
    remainder = a % b
    return quotient, remainder

q, r = divide(17, 5)
print(f"17 / 5 = {q} remainder {r}")
# Output: 17 / 5 = 3 remainder 2

This is called tuple unpacking. The function returns a tuple (3, 2), and we unpack it into two variables.

*args: Variable Positional Arguments

Sometimes you do not know how many arguments a function will receive. Use *args to accept any number of positional arguments:

def sum_all(*args: int) -> int:
    total = 0
    for num in args:
        total += num
    return total

print(sum_all(1, 2, 3))         # 6
print(sum_all(10, 20, 30, 40))  # 100
print(sum_all())                 # 0

Inside the function, args is a tuple containing all the arguments.

**kwargs: Variable Keyword Arguments

Use **kwargs to accept any number of keyword arguments:

def build_profile(**kwargs: str) -> dict[str, str]:
    return dict(kwargs)

profile = build_profile(name="Alex", city="Berlin", role="developer")
print(profile)
# Output: {'name': 'Alex', 'city': 'Berlin', 'role': 'developer'}

Inside the function, kwargs is a dictionary.

Combining *args and **kwargs

You can use both in the same function:

def log_message(level: str, *args: str, **kwargs: str) -> str:
    message = " ".join(args)
    meta = ", ".join(f"{k}={v}" for k, v in kwargs.items())
    if meta:
        return f"[{level}] {message} ({meta})"
    return f"[{level}] {message}"

print(log_message("INFO", "User", "logged", "in", user="Alex"))
# Output: [INFO] User logged in (user=Alex)

The order matters: regular parameters first, then *args, then **kwargs.

Keyword-Only Arguments

Put * before the parameters that must be passed by name:

def create_user(name: str, *, email: str, active: bool = True) -> dict:
    return {"name": name, "email": email, "active": active}

# CORRECT: email is passed by name
user = create_user("Alex", email="alex@example.com")

# WRONG: this raises TypeError
# user = create_user("Alex", "alex@example.com")

The * separator forces email and active to be keyword-only. This prevents mistakes when a function has many parameters.

Lambda Functions

A lambda is a small, anonymous function. It can have any number of arguments but only one expression:

double = lambda x: x * 2
add = lambda a, b: a + b

print(double(5))   # 10
print(add(3, 4))   # 7

Lambdas are most useful as arguments to other functions:

# Sort a list of dicts by age
users = [
    {"name": "Sam", "age": 30},
    {"name": "Alex", "age": 25},
    {"name": "Jordan", "age": 28},
]

sorted_users = sorted(users, key=lambda user: user["age"])
# Result: Alex (25), Jordan (28), Sam (30)

Lambda with map() and filter()

numbers = [1, 2, 3, 4, 5]

# Double only the even numbers
evens = filter(lambda x: x % 2 == 0, numbers)
doubled = list(map(lambda x: x * 2, evens))
print(doubled)  # [4, 8]

In practice, list comprehensions are often cleaner than map() and filter():

# Same thing with a list comprehension
doubled = [x * 2 for x in numbers if x % 2 == 0]

Use lambdas for short, one-off functions. For anything more complex, use a regular def function.

Closures

A closure is a function that remembers variables from its enclosing scope:

def make_multiplier(factor: int):
    def multiplier(n: int) -> int:
        return n * factor
    return multiplier

triple = make_multiplier(3)
print(triple(5))   # 15
print(triple(10))  # 30

The inner function multiplier captures the factor variable. Even after make_multiplier finishes, multiplier still has access to factor.

Closures with State

Use nonlocal to modify a captured variable:

def make_counter(start: int = 0):
    count = start

    def counter() -> int:
        nonlocal count
        count += 1
        return count

    return counter

counter = make_counter()
print(counter())  # 1
print(counter())  # 2
print(counter())  # 3

Without nonlocal, Python treats count as a local variable inside counter, and you get an error.

Closures are the foundation of decorators, which we will cover in a later tutorial.

Docstrings

A docstring is a string at the top of a function that describes what it does:

def calculate_bmi(weight_kg: float, height_m: float) -> float:
    """Calculate Body Mass Index (BMI).

    Args:
        weight_kg: Weight in kilograms.
        height_m: Height in meters.

    Returns:
        The BMI value as a float.

    Raises:
        ValueError: If height_m is zero or negative.
    """
    if height_m <= 0:
        raise ValueError("Height must be positive.")
    return weight_kg / (height_m ** 2)

Docstrings help other developers understand your code. They also appear when you use help():

help(calculate_bmi)

The format above is called Google style. There are other styles (NumPy, Sphinx), but Google style is the most readable.

Scope: Local vs Global

Variables defined inside a function are local. They do not exist outside the function:

def my_function():
    local_var = "I am local"
    print(local_var)

my_function()
# print(local_var)  # ERROR: NameError

Variables defined outside any function are global:

global_var = "I am global"

def my_function():
    print(global_var)  # Can READ global variables

my_function()  # Output: I am global

Python follows the LEGB rule to find variables:

  1. Local — inside the current function
  2. Enclosing — inside enclosing functions (closures)
  3. Global — at the module level
  4. Built-in — Python’s built-in names (print, len, etc.)

Python checks these scopes in order. If a name is found in the local scope, it uses that. Otherwise, it checks the next scope.

The Mutable Default Argument Gotcha

This is the most famous Python mistake. Do not use a mutable object (list, dict, set) as a default argument:

# BAD: the list is shared between ALL calls!
def append_to_list(item, lst=[]):
    lst.append(item)
    return lst

result1 = append_to_list(1)  # [1]
result2 = append_to_list(2)  # [1, 2] — OOPS!

The default list is created once when the function is defined. Every call shares the same list.

The fix is to use None as the default:

# GOOD: create a new list each time
def append_to_list(item, lst=None):
    if lst is None:
        lst = []
    lst.append(item)
    return lst

result1 = append_to_list(1)  # [1]
result2 = append_to_list(2)  # [2] — correct!

This pattern applies to all mutable defaults: lists, dictionaries, and sets.

Higher-Order Functions

A higher-order function is a function that takes another function as an argument or returns a function. Python has several built-in higher-order functions.

sorted() with key

words = ["banana", "apple", "cherry"]
# Sort by length
print(sorted(words, key=len))  # ['apple', 'banana', 'cherry']

# Sort by last character
print(sorted(words, key=lambda w: w[-1]))  # ['banana', 'apple', 'cherry']

map() — Transform Each Item

numbers = [1, 2, 3, 4, 5]
squared = list(map(lambda x: x ** 2, numbers))
print(squared)  # [1, 4, 9, 16, 25]

filter() — Keep Matching Items

numbers = [1, 2, 3, 4, 5, 6]
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens)  # [2, 4, 6]

In practice, list comprehensions are usually preferred over map() and filter():

# These are equivalent and more readable
squared = [x ** 2 for x in numbers]
evens = [x for x in numbers if x % 2 == 0]

Use map() and filter() when you have an existing function to apply. Use list comprehensions for inline transformations.

Practical Example: Building a Calculator

Let’s combine functions, dictionaries, and lambdas:

operations = {
    "+": lambda a, b: a + b,
    "-": lambda a, b: a - b,
    "*": lambda a, b: a * b,
    "/": lambda a, b: a / b if b != 0 else "Error: division by zero",
}

def calculate(a: float, op: str, b: float) -> float | str:
    """Perform a calculation using the operation string."""
    if op not in operations:
        return f"Error: unknown operator '{op}'"
    return operations[op](a, b)

print(calculate(10, "+", 5))   # 15
print(calculate(10, "/", 0))   # Error: division by zero
print(calculate(10, "^", 2))   # Error: unknown operator '^'

This pattern uses a dictionary of functions. It is cleaner than a chain of if/elif statements.

Common Mistakes

Forgetting return

If a function does not have a return statement, it returns None:

def add(a, b):
    result = a + b
    # Forgot to return!

value = add(3, 4)
print(value)  # None — not 7!

Modifying Global Variables

Avoid using global to modify global variables inside functions. It makes code hard to debug:

# BAD
counter = 0
def increment():
    global counter
    counter += 1

# GOOD: pass and return values
def increment(counter: int) -> int:
    return counter + 1

Functions should be predictable. Give them input, get output. Avoid side effects.

Summary

Here is a quick reference for everything we covered:

ConceptSyntaxExample
Basic functiondef func():def greet(name): return f"Hi, {name}"
Default paramsdef func(x=1):def greet(name="World"):
*argsdef func(*args):sum_all(1, 2, 3)
**kwargsdef func(**kwargs):build_profile(name="Alex")
Keyword-onlydef func(*, key):create_user("Alex", email="...")
Lambdalambda x: exprdouble = lambda x: x * 2
ClosureNested functiondef make_mult(n): def m(x): return x*n
Type hintsdef f(x: int) -> str:Return type annotation
Docstring"""Description"""First line of function body

Functions are the building blocks of clean code. Keep them short, focused, and well-named. Each function should do one thing and do it well.

Source Code

You can find the code for this tutorial on GitHub:

kemalcodes/python-tutorial — tutorial-05-functions

Run the examples:

python src/py05_functions.py

Run the tests:

python -m pytest tests/test_py05.py -v

What’s Next?

In the next tutorial, we will learn about Python’s built-in data structures: lists, dictionaries, sets, and tuples. These are the tools you use every day in Python.