Python Functions & Type Hints: Write Code That Explains Itself
Master Python functions from basics to advanced patterns ā type hints, *args/**kwargs, decorators, closures, comprehensions, and the functional tools used in real pipelines.
Functions Are the Core Unit of Python
Everything in Python is a function call or an object. Mastering how functions work ā including how they're defined, called, composed, and typed ā is the single highest-leverage skill in the language.
1. Function Basics
def greet(name: str) -> str:
return f"Hello, {name}!"
print(greet("Asma")) # Hello, Asma!Return values
def divide(a: float, b: float) -> float | None:
if b == 0:
return None
return a / b
result = divide(10, 2) # 5.0
nothing = divide(10, 0) # NoneAlways return a value ā relying on implicit None from a missing return is a common source of bugs.
Multiple return values (tuple unpacking)
def min_max(numbers: list[int]) -> tuple[int, int]:
return min(numbers), max(numbers)
low, high = min_max([3, 1, 9, 2, 7])
print(low, high) # 1 92. Type Hints
Type hints don't run at runtime ā they're for your editor and for mypy. Write them on every function you define.
Built-in types
def process(
name: str,
count: int,
price: float,
active: bool,
tags: list[str],
meta: dict[str, str],
) -> None:
...Optional values
from typing import Optional
def find_user(user_id: int) -> Optional[str]: # old style
...
def find_user(user_id: int) -> str | None: # Python 3.10+ (preferred)
...Union types
def parse_id(value: str | int) -> int:
return int(value)Literal types
from typing import Literal
LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR"]
def set_log_level(level: LogLevel) -> None:
...TypeAlias and type (Python 3.12)
from typing import TypeAlias
UserId: TypeAlias = int
Pipeline: TypeAlias = list[dict[str, str]]
# Python 3.12+
type UserId = intCallable types
from typing import Callable
def apply(fn: Callable[[int, int], int], a: int, b: int) -> int:
return fn(a, b)
apply(lambda x, y: x + y, 3, 4) # 73. Default Arguments and Keyword Arguments
def create_report(
title: str,
rows: list[dict],
format: str = "csv",
include_header: bool = True,
) -> str:
...
# positional
create_report("Sales", data)
# keyword (any order, explicit)
create_report("Sales", data, include_header=False, format="json")Never use mutable defaults ā this is a classic Python trap:
# WRONG ā the list is shared across all calls
def add_item(item: str, items: list[str] = []) -> list[str]:
items.append(item)
return items
# CORRECT ā use None as sentinel
def add_item(item: str, items: list[str] | None = None) -> list[str]:
if items is None:
items = []
items.append(item)
return items4. *args and **kwargs
*args ā variable positional arguments
def sum_all(*numbers: int) -> int:
return sum(numbers)
sum_all(1, 2, 3, 4, 5) # 15**kwargs ā variable keyword arguments
def build_query(**filters: str) -> str:
parts = [f"{k}={v}" for k, v in filters.items()]
return "&".join(parts)
build_query(status="active", role="admin") # status=active&role=adminCombining all parameter types
def log(
message: str,
*tags: str,
level: str = "INFO",
**context: str,
) -> None:
print(f"[{level}] {message} | tags={tags} | ctx={context}")
log("user created", "auth", "user", level="DEBUG", user_id="123")Unpacking with * and **
def add(a: int, b: int, c: int) -> int:
return a + b + c
nums = [1, 2, 3]
add(*nums) # 6
opts = {"b": 2, "c": 3}
add(1, **opts) # 65. Closures
A closure is a function that remembers variables from the scope where it was defined, even after that scope has exited.
def make_multiplier(factor: int) -> Callable[[int], int]:
def multiply(x: int) -> int:
return x * factor # `factor` is captured from outer scope
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
double(5) # 10
triple(5) # 15Real use case: configurable pipeline steps
def make_prefix_adder(prefix: str) -> Callable[[str], str]:
def add_prefix(value: str) -> str:
return f"{prefix}:{value}"
return add_prefix
add_env = make_prefix_adder("prod")
add_env("database") # "prod:database"6. Decorators
A decorator is a function that wraps another function to add behaviour before, after, or around it.
Basic decorator
from functools import wraps
from typing import Callable, Any
def log_call(fn: Callable) -> Callable:
@wraps(fn) # preserves the original function's metadata
def wrapper(*args: Any, **kwargs: Any) -> Any:
print(f"Calling {fn.__name__}")
result = fn(*args, **kwargs)
print(f"{fn.__name__} returned {result!r}")
return result
return wrapper
@log_call
def add(a: int, b: int) -> int:
return a + b
add(2, 3)
# Calling add
# add returned 5Decorator with arguments (decorator factory)
def retry(times: int = 3) -> Callable:
def decorator(fn: Callable) -> Callable:
@wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
for attempt in range(times):
try:
return fn(*args, **kwargs)
except Exception as e:
if attempt == times - 1:
raise
print(f"Retry {attempt + 1}/{times} after error: {e}")
return wrapper
return decorator
@retry(times=3)
def fetch_data(url: str) -> dict:
...Timing decorator (used in pipelines)
import time
def timed(fn: Callable) -> Callable:
@wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
start = time.perf_counter()
result = fn(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{fn.__name__} took {elapsed:.3f}s")
return result
return wrapper7. Lambda Functions
Lambdas are anonymous single-expression functions. Use them for short transformations passed as arguments.
# single expression only
double = lambda x: x * 2
# common use: as sort keys
users = [{"name": "Bob", "age": 30}, {"name": "Alice", "age": 25}]
users.sort(key=lambda u: u["age"])
# with map/filter
numbers = [1, 2, 3, 4, 5, 6]
evens = list(filter(lambda n: n % 2 == 0, numbers)) # [2, 4, 6]
doubled = list(map(lambda n: n * 2, numbers)) # [2, 4, 6, 8, 10, 12]Prefer comprehensions over map/filter ā they're more readable:
evens = [n for n in numbers if n % 2 == 0]
doubled = [n * 2 for n in numbers]8. Comprehensions
List comprehensions
# [expression for item in iterable if condition]
squares = [x**2 for x in range(10)]
clean = [s.strip().lower() for s in raw_strings if s.strip()]Dict comprehensions
# {key_expr: value_expr for item in iterable}
word_lengths = {word: len(word) for word in ["hello", "world", "python"]}
inverted = {v: k for k, v in original_dict.items()}Set comprehensions
unique_domains = {email.split("@")[1] for email in emails}Generator expressions (memory-efficient)
# doesn't build the full list in memory
total = sum(x**2 for x in range(1_000_000))
# use when piping into another function
max_length = max(len(s) for s in strings)9. functools Utilities
from functools import reduce, partial, lru_cache
# lru_cache ā memoize expensive pure functions
@lru_cache(maxsize=128)
def fibonacci(n: int) -> int:
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
# partial ā pre-fill arguments
from functools import partial
def power(base: int, exp: int) -> int:
return base ** exp
square = partial(power, exp=2)
cube = partial(power, exp=3)
square(5) # 25
cube(3) # 27
# reduce ā fold a list into a single value
product = reduce(lambda acc, x: acc * x, [1, 2, 3, 4, 5]) # 12010. Real-World Function Patterns for Pipelines
Step-based pipeline
from typing import TypeVar
T = TypeVar("T")
Step = Callable[[T], T]
def run_pipeline(data: T, *steps: Step) -> T:
result = data
for step in steps:
result = step(result)
return result
# Usage
def normalize(text: str) -> str:
return text.strip().lower()
def remove_punctuation(text: str) -> str:
return "".join(c for c in text if c.isalnum() or c.isspace())
def tokenize(text: str) -> list[str]:
return text.split()
clean_tokens = run_pipeline(
" Hello, World! ",
normalize,
remove_punctuation,
tokenize,
)
# ["hello", "world"]Guard clauses (early return pattern)
# AVOID deep nesting
def process_order(order: dict) -> str:
if order:
if order.get("status") == "pending":
if order.get("items"):
return "processing"
# PREFER guard clauses
def process_order(order: dict) -> str:
if not order:
return "no order"
if order.get("status") != "pending":
return "not pending"
if not order.get("items"):
return "no items"
return "processing"Exercises
Exercise 1: Write a clamp(value, min_val, max_val) function with full type hints that returns value restricted to [min_val, max_val].
Exercise 2: Write a decorator @validate_positive that raises ValueError if any positional argument is not a positive number.
Exercise 3: Build a compose(*fns) function that takes any number of single-argument functions and returns a new function that applies them left-to-right.
Exercise 4: Write a list comprehension that takes a list of file paths and returns only paths where the file extension is .py and the filename starts with test_.
Summary
| Pattern | Use When |
|---------|---------|
| Type hints | Always ā on every function parameter and return |
| Default None sentinel | When a mutable default (list, dict) is needed |
| *args / **kwargs | Forwarding or collecting variable arguments |
| Closures | Capturing config at construction time |
| Decorators | Cross-cutting concerns: logging, retry, timing, auth |
| Comprehensions | Transforming/filtering iterables (prefer over map/filter) |
| lru_cache | Pure functions called repeatedly with the same args |
| Guard clauses | Avoiding deeply nested conditionals |
Next: applying all of this inside classes and OOP patterns.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.