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 functiongreet— 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:
- Local — inside the current function
- Enclosing — inside enclosing functions (closures)
- Global — at the module level
- 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:
| Concept | Syntax | Example |
|---|---|---|
| Basic function | def func(): | def greet(name): return f"Hi, {name}" |
| Default params | def func(x=1): | def greet(name="World"): |
| *args | def func(*args): | sum_all(1, 2, 3) |
| **kwargs | def func(**kwargs): | build_profile(name="Alex") |
| Keyword-only | def func(*, key): | create_user("Alex", email="...") |
| Lambda | lambda x: expr | double = lambda x: x * 2 |
| Closure | Nested function | def make_mult(n): def m(x): return x*n |
| Type hints | def 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.
Related Articles
- Python Tutorial #4: Control Flow — if, for, while, match
- Python Tutorial #3: Variables, Types, and f-Strings — variables and data types
- Python Cheat Sheet — quick reference for Python syntax