Skip to main content
Published / updated

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
  • *args and **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

MessageCauseFix
TypeError: f() missing 1 required positional argumentArgument not suppliedPass it, or give a default
TypeError: f() got an unexpected keyword argumentName mismatchCheck the signature
SyntaxError: non-default argument follows default argumentOrderingDefaults come last
The function returns NoneNo return on that pathReturn on every path
A type hint is ignored at run timeHints are not enforcedValidate 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
  • global instead 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/filter where a comprehension reads better
  • A decorator without functools.wraps
  • lru_cache on a function that reads changing data
  • Returning a tuple of five values instead of a dataclass

Practice

  1. Write calculate_percentage with type hints, a docstring and a guard clause. Call it with keywords.
  2. Write a function with group=[] as a default. Call it three times and confirm the accumulation. Fix it.
  3. Put a defaulted parameter before a non-defaulted one. Record the SyntaxError.
  4. Write save_student(student, True, False), then make the booleans keyword-only and compare readability.
  5. Write *args and **kwargs versions of a logging helper.
  6. Annotate a function returning Student | None, then use the result without a check. Run mypy.
  7. Remove a return annotation and forget to return on one branch. Confirm mypy catches it with the annotation and not without.
  8. Assign to a module-level variable inside a function without global. Record the UnboundLocalError.
  9. Rewrite it to return the value instead.
  10. Write make_counter with nonlocal and confirm the closure.
  11. Shadow list with a variable, then call list("abc").
  12. Build three lambdas in a loop and confirm they all print the same value. Fix with a default argument.
  13. Sort students three ways with attrgetter.
  14. Rewrite a map/filter chain as a comprehension.
  15. Write a @timed decorator with and without functools.wraps. Compare help() output.
  16. 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

  1. Why does a mutable default argument accumulate between calls?
  2. What does * in a parameter list enforce, and why use it?
  3. Why does assigning to a module-level name inside a function raise UnboundLocalError?
  4. What does functools.wraps preserve?

Next: Errors and exceptions