Functions
Before you start
You need: collections (Article 03).
Time: about 45 minutes, plus the practice.
Learning objective
Write functions with clear signatures and correct parameter handling, and use type hints that catch errors before they run.
Topics
- Defining and calling
- Parameters: positional, keyword, default
*argsand**kwargs- Type hints
- Return values
- Docstrings
- Scope
- Lambdas
- Higher-order functions
Defining
def calculate_percentage(marks_obtained: int, max_marks: int) -> float:
"""Return the percentage for the given marks."""
if max_marks <= 0:
raise ValueError("max_marks must be positive")
return (marks_obtained / max_marks) * 100
percentage = calculate_percentage(87, 100)
percentage = calculate_percentage(marks_obtained=87, max_marks=100)
Keyword arguments make a call self-documenting, and they are order-independent. For a call like create_student("Ravi", "10th", "A", True, False) nobody can tell what the booleans mean without opening the function.
A function with no explicit return returns None.
Parameters
def build_roll_number(year: int, sequence: int, school_code: str = "NCA") -> str:
return f"{school_code}-{year}-{sequence:04d}"
build_roll_number(2024, 12)
build_roll_number(2024, 12, "NCB")
build_roll_number(sequence=12, year=2024)
Parameters with defaults must come after those without. Otherwise Python cannot tell which argument is which, and it is a SyntaxError.
The mutable default trap
# Wrong — the list is created once, at definition
def add_student(name: str, group: list[str] = []) -> list[str]:
group.append(name)
return group
add_student("Ravi") # ["Ravi"]
add_student("Priya") # ["Ravi", "Priya"] — the same list
# Right
def add_student(name: str, group: list[str] | None = None) -> list[str]:
if group is None:
group = []
group.append(name)
return group
Python's most famous gotcha. It applies to [], {} and set() — anything mutable evaluated once at definition time.
*args and **kwargs
def total_marks(*marks: int) -> int:
return sum(marks)
total_marks(87, 72, 91) # 250
total_marks(*[87, 72, 91]) # unpack a list
def create_student(**fields: object) -> dict[str, object]:
return {"status": "Active", **fields}
create_student(name="Ravi Kumar", roll_number="NCA-2024-0012")
create_student(**student_dict)
def log_call(func_name: str, *args: object, **kwargs: object) -> None:
print(f"{func_name}({args}, {kwargs})")
*args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict. The names are convention — *items works — but args and kwargs are universal.
Order in a signature: positional, *args, keyword-only, **kwargs.
Enforcing call style
def record_payment(account_id: int, amount: Decimal, *, mode: str, collected_by: str) -> int:
...
Everything after * is keyword-only:
record_payment(42, Decimal("8000"), mode="Cash", collected_by="admin")
record_payment(42, Decimal("8000"), "Cash", "admin") # TypeError
Use it for booleans and any parameter whose meaning is not obvious at the call site. save(student, True, False) is unreadable; save(student, notify=True, validate=False) is not.
def divide(a: float, b: float, /) -> float:
return a / b
Everything before / is positional-only. Rare in application code; you meet it in the standard library.
Type hints
def find_student(roll_number: str) -> Student | None:
...
def get_students(class_name: str, section: str | None = None) -> list[Student]:
...
def group_by_class(students: list[Student]) -> dict[str, list[Student]]:
...
def process(records: Iterable[dict[str, object]]) -> None:
...
from collections.abc import Callable, Iterable, Sequence
def apply_to_each(items: Sequence[int], transform: Callable[[int], int]) -> list[int]:
return [transform(item) for item in items]
Hints are not enforced at run time. Passing a string where an int is annotated runs happily until something fails. They exist for editors and for static checkers:
pip install mypy
mypy student_records.py
mypy catches a genuine class of error — a function returning None on one path, a str | None used without a check — before the code runs.
Annotate parameters and return types; let local variables infer. The return annotation is what catches a branch that forgets to return.
Prefer the abstract types for parameters:
def total(marks: Iterable[int]) -> int: # accepts a list, tuple, generator
def total(marks: list[int]) -> int: # accepts only a list
Accept the most general type you can use, and return the most specific type you have.
Return values
def get_grade(percentage: float) -> str:
if percentage >= 90:
return "A+"
if percentage >= 80:
return "A"
return "C"
Multiple return statements are idiomatic — guard clauses read better than one deeply nested expression.
def split_roll_number(roll_number: str) -> tuple[str, int, int]:
code, year, sequence = roll_number.split("-")
return code, int(year), int(sequence)
code, year, sequence = split_roll_number("NCA-2024-0012")
Returning a tuple is common. Beyond three values it becomes hard to read — use a dataclass:
from dataclasses import dataclass
@dataclass
class ExamSummary:
total: int
average: float
highest: int
pass_count: int
def summarise(results: list[ExamResult]) -> ExamSummary:
...
summary = summarise(results)
summary.average # rather than summary[1]
A function returning None on failure forces every caller to check. That is often right — and sometimes an exception is clearer:
def find_student(roll_number: str) -> Student | None:
"""Return the student, or None when not found."""
def get_student(roll_number: str) -> Student:
"""Return the student.
Raises:
StudentNotFoundError: if no student has that roll number.
"""
Return None when absence is an ordinary outcome; raise when it means the caller made a mistake.
Docstrings
def record_payment(account_id: int, amount: Decimal, *, mode: str) -> int:
"""Record a fee payment against an account.
Args:
account_id: The fee account to credit.
amount: The payment amount. Must be positive.
mode: One of Cash, Online, Cheque or DemandDraft.
Returns:
The generated payment id.
Raises:
ValueError: If amount is not positive.
PaymentExceedsBalanceError: If amount exceeds the outstanding balance.
"""
The docstring is the first statement in the function. help(record_payment) reads it; a # comment above the def is invisible to tooling.
Document what a caller cannot see from the signature: units, valid ranges, what is raised, and any side effect. Repeating the parameter names adds nothing.
Scope
count = 0 # module scope
def increment():
count = count + 1 # UnboundLocalError
Assigning to a name anywhere in a function makes it local for the whole function — so the read on the right-hand side refers to a local that does not exist yet.
def increment():
global count
count += 1
Avoid global. A function modifying module state is hard to test and hard to reason about. Return the new value instead:
def increment(count: int) -> int:
return count + 1
def make_counter():
count = 0
def increment() -> int:
nonlocal count
count += 1
return count
return increment
counter = make_counter()
counter() # 1
counter() # 2
nonlocal binds to the enclosing function's variable. This is a closure — the inner function keeps access to the outer scope after it returns.
The lookup order is Local, Enclosing, Global, Built-in.
Shadowing a built-in is a common beginner error:
list = [1, 2, 3]
list("abc") # TypeError — list is no longer the built-in
list, dict, str, id, type, sum, max and input are all shadowable, and the resulting error appears far from the assignment.
Lambdas
square = lambda x: x * x # works, but do not do this
students.sort(key=lambda s: s.name)
sorted(results, key=lambda r: r.marks_obtained, reverse=True)
filtered = filter(lambda s: s.status == "Active", students)
A lambda is a single-expression anonymous function. Its only good use is as a short argument to another function — a sort key, a filter predicate.
# Wrong — a named function is clearer and gets a docstring
calculate = lambda marks, max_marks: (marks / max_marks) * 100
# Right
def calculate_percentage(marks: int, max_marks: int) -> float:
return (marks / max_marks) * 100
Assigning a lambda to a name gains nothing over def and loses the name in tracebacks. PEP 8 says so explicitly.
For attribute and item access, operator is clearer than a lambda:
from operator import attrgetter, itemgetter
students.sort(key=attrgetter("class_name", "name"))
rows.sort(key=itemgetter(2))
The late-binding trap
# All three print 2
handlers = [lambda: print(i) for i in range(3)]
for handler in handlers:
handler()
The lambda captures the variable, not its value, and by the time they run i is 2.
# Bind the current value as a default
handlers = [lambda i=i: print(i) for i in range(3)]
The same closure problem as var in JavaScript loops, and it appears whenever handlers are built in a loop.
Higher-order functions
def apply_to_all(items: list[int], transform: Callable[[int], int]) -> list[int]:
return [transform(item) for item in items]
apply_to_all([1, 2, 3], lambda x: x * 2)
from functools import reduce
names = list(map(lambda s: s.name, students))
active = list(filter(lambda s: s.status == "Active", students))
total = reduce(lambda acc, r: acc + r.marks, results, 0)
Prefer comprehensions to map and filter — they are more readable and do not need list() around them:
names = [s.name for s in students]
active = [s for s in students if s.status == "Active"]
total = sum(r.marks for r in results)
reduce is rarely clearer than a loop or sum.
Decorators
import functools
import time
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.3f}s")
return wrapper
@timed
def load_students() -> list[Student]:
...
@timed is shorthand for load_students = timed(load_students).
@functools.wraps matters. Without it the wrapper replaces the function's __name__ and docstring, so help() and tracebacks show wrapper instead of load_students.
@functools.lru_cache(maxsize=128)
def get_class_names(school_id: int) -> list[str]:
...
lru_cache memoises by arguments. Use it only on pure functions — caching a function that reads a database means stale data forever.
You will meet decorators constantly in FastAPI (@app.get) and in test frameworks; writing your own is occasional.
Errors you will hit
| Message | Cause | Fix |
|---|---|---|
TypeError: f() missing 1 required positional argument | Argument not supplied | Pass it, or give a default |
TypeError: f() got an unexpected keyword argument | Name mismatch | Check the signature |
SyntaxError: non-default argument follows default argument | Ordering | Defaults come last |
The function returns None | No return on that path | Return on every path |
| A type hint is ignored at run time | Hints are not enforced | Validate if it matters |
Type hints document and enable tooling; they do not enforce. Passing a string where int is hinted runs happily until something breaks downstream.
Common mistakes
- A mutable default argument
- A default parameter before a non-default one
- Booleans passed positionally instead of keyword-only
- Type hints treated as run-time enforcement
- No return annotation, so a missing return goes unnoticed
globalinstead of returning a value- Assigning then reading a name, giving
UnboundLocalError - Shadowing a built-in
- A lambda assigned to a name
- Late binding in a loop of lambdas
map/filterwhere a comprehension reads better- A decorator without
functools.wraps lru_cacheon a function that reads changing data- Returning a tuple of five values instead of a dataclass
Practice
- Write
calculate_percentagewith type hints, a docstring and a guard clause. Call it with keywords. - Write a function with
group=[]as a default. Call it three times and confirm the accumulation. Fix it. - Put a defaulted parameter before a non-defaulted one. Record the
SyntaxError. - Write
save_student(student, True, False), then make the booleans keyword-only and compare readability. - Write
*argsand**kwargsversions of a logging helper. - Annotate a function returning
Student | None, then use the result without a check. Runmypy. - Remove a return annotation and forget to return on one branch. Confirm
mypycatches it with the annotation and not without. - Assign to a module-level variable inside a function without
global. Record theUnboundLocalError. - Rewrite it to return the value instead.
- Write
make_counterwithnonlocaland confirm the closure. - Shadow
listwith a variable, then calllist("abc"). - Build three lambdas in a loop and confirm they all print the same value. Fix with a default argument.
- Sort students three ways with
attrgetter. - Rewrite a
map/filterchain as a comprehension. - Write a
@timeddecorator with and withoutfunctools.wraps. Comparehelp()output. - Return five values as a tuple, then as a dataclass. Compare the call site.
Exercises 2, 8 and 12 correspond to three real bugs.
You can now
- Write functions with clear signatures and defaults
- Use keyword and positional arguments correctly
- Return a value on every path
- Add type hints and say what they do and do not do
- Write a docstring worth reading
Review questions
- Why does a mutable default argument accumulate between calls?
- What does
*in a parameter list enforce, and why use it? - Why does assigning to a module-level name inside a function raise
UnboundLocalError? - What does
functools.wrapspreserve?
Next: Errors and exceptions