Functions
Functions are first-class objects in Python: they may be passed as arguments, returned, stored in data structures, and dynamically created. The function-definition surface is rich — positional, keyword, default, positional-only, keyword-only, *args, **kwargs — admitting flexible call conventions. Lambda expressions admit anonymous one-expression functions. Closures admit functions that capture variables from their enclosing scope. The combination — first-class functions, lexical closures, varied parameter forms — admits substantial functional and higher-order programming.
This page covers function definition, the parameter forms, lambdas, closures, and the conventional patterns. The decorator surface is in Decorators; the broader functional facilities (map, filter, functools) are in Functional.
Function definition
The def keyword introduces a function:
def square(n: int) -> int:
return n * n
def greet(name: str, age: int = 0) -> str:
return f"Hello, {name}, age {age}"
The parts:
- Name — the function’s identifier.
- Parameter list — comma-separated parameters, optionally with defaults, type hints, and special markers.
- Return type — optional annotation after
->. - Body — indented block;
returnproduces a value (otherwiseNone). - Docstring — an optional string literal as the first statement; conventional for documentation.
A typical function:
def fibonacci(n: int) -> int:
"""Return the nth Fibonacci number."""
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
The docstring is accessible via fibonacci.__doc__ and is consumed by help(), IDEs, and documentation generators.
Parameter forms
Python admits substantial parameter flexibility:
Positional and keyword
def add(x, y):
return x + y
add(1, 2) # positional: x=1, y=2
add(x=1, y=2) # keyword
add(1, y=2) # mixed
add(y=2, x=1) # keyword in any order
Every parameter (by default) admits both positional and keyword call. The choice is the caller’s.
Default values
def greet(name, greeting="Hello"):
return f"{greeting}, {name}"
greet("alice") # "Hello, alice"
greet("alice", "Hi") # "Hi, alice"
greet("alice", greeting="Hey") # "Hey, alice"
Defaults make the parameter optional. Defaults are evaluated once, at function-definition time — a common pitfall:
# WRONG: the default is shared across calls
def append_item(item, items=[]):
items.append(item)
return items
append_item(1) # [1]
append_item(2) # [1, 2] — the SAME list, mutated
append_item(3) # [1, 2, 3]
# Right: use None as the sentinel
def append_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
The conventional defence: never use a mutable default; use None and create the mutable inside the body.
Variadic positional (*args)
def total(*args):
return sum(args)
total(1, 2, 3) # 6
total(1, 2, 3, 4, 5) # 15
The *args collects extra positional arguments into a tuple. The conventional name is args; any name works.
To unpack a sequence as positional arguments:
xs = [1, 2, 3, 4]
total(*xs) # equivalent to total(1, 2, 3, 4)
Variadic keyword (**kwargs)
def configure(**kwargs):
for key, value in kwargs.items():
print(f"{key}: {value}")
configure(host="localhost", port=8080, timeout=30)
The **kwargs collects extra keyword arguments into a dict. The conventional name is kwargs.
To unpack a dict as keyword arguments:
config = {"host": "localhost", "port": 8080}
connect(**config) # equivalent to connect(host="localhost", port=8080)
Positional-only and keyword-only
The / separator makes the preceding parameters positional-only; the * separator makes the subsequent parameters keyword-only:
def divide(num, denom, /):
"""num and denom must be positional"""
return num / denom
divide(10, 2) # OK
divide(num=10, denom=2) # ERROR; positional-only
def fetch(url, *, timeout=30, retries=3):
"""timeout and retries must be keyword"""
pass
fetch("http://example.com", timeout=60) # OK
fetch("http://example.com", 60) # ERROR; keyword-only
The mechanisms admit clearer API design: the parameters that are conventionally named (timeout, retries) require the keyword form; the parameters that are conventionally positional (url) admit either.
The full form combining all:
def func(pos1, pos2, /, pos_or_kw, *args, kw_only, **kwargs):
pass
The / separator makes pos1, pos2 positional-only. The *args (or * alone) makes the parameters after it keyword-only.
Return values
Functions return a value with return:
def square(n):
return n * n
def maybe_compute(x):
if x < 0:
return None # explicit return of None
return x * 2
Without a return (or with bare return), the function returns None:
def announce(message):
print(message)
# implicitly returns None
result = announce("hello") # None
For multiple values, return a tuple:
def divmod_self(a, b):
return a // b, a % b
quotient, remainder = divmod_self(10, 3)
The tuple is the conventional Python form for multiple return values; named tuples or dataclasses for cases with more than two.
Lambda expressions
A lambda is an anonymous one-expression function:
square = lambda x: x ** 2
add = lambda x, y: x + y
square(5) # 25
add(3, 4) # 7
The form: lambda <params>: <expression>. The body must be a single expression; statements are not admitted.
Lambdas are first-class values; they may be passed, returned, and stored:
sorted(items, key=lambda x: x.priority)
list(map(lambda n: n ** 2, range(10)))
# As a callback:
button.on_click = lambda: print("clicked")
The conventional Python style limits lambdas to short, single-expression bodies. For substantial bodies, a named def is conventionally clearer.
Closures
A function defined inside another function captures the enclosing scope’s variables:
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
c = make_counter()
c() # 1
c() # 2
c() # 3
The nonlocal declaration is required to modify the enclosing variable; reading admits the implicit lookup. The mechanism admits stateful closures.
For read-only captures:
def make_multiplier(factor):
def multiply(x):
return x * factor # factor is captured from the enclosing scope
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
double(5) # 10
triple(5) # 15
The closure captures factor by reference — modifying factor after make_multiplier returns is invisible (because the surrounding function has already returned). For mutable captures, nonlocal is required.
First-class functions
Functions in Python are objects with attributes:
def square(n):
"""Compute the square of n."""
return n * n
square.__name__ # 'square'
square.__doc__ # 'Compute the square of n.'
square.__module__ # the module where defined
square.__defaults__ # tuple of default values
# Functions can be assigned, passed, returned:
fn = square
print(fn(5)) # 25
# Stored in a dict:
operations = {
"square": square,
"double": lambda n: n * 2,
}
operations["square"](5) # 25
The mechanism is the foundation of higher-order programming, decorators, callbacks, and dispatch tables.
Higher-order functions
Functions that take functions as arguments or return functions:
def apply_twice(f, x):
return f(f(x))
apply_twice(square, 3) # square(square(3)) = 81
def make_adder(n):
return lambda x: x + n
add5 = make_adder(5)
add5(10) # 15
The standard library provides several:
list(map(square, [1, 2, 3, 4])) # [1, 4, 9, 16]
list(filter(lambda n: n > 0, nums))
sorted(items, key=lambda x: x.name)
max(items, key=lambda x: x.value)
The treatment is in Functional.
Decorators
A decorator is a function that takes a function and returns a (typically wrapped) function. The @ syntax admits compact application:
def with_logging(f):
def wrapped(*args, **kwargs):
print(f"calling {f.__name__}")
result = f(*args, **kwargs)
print(f" → {result}")
return result
return wrapped
@with_logging
def square(n):
return n * n
square(5)
# calling square
# → 25
The @with_logging is equivalent to square = with_logging(square) after the function’s definition. The full treatment is in Decorators.
Type hints on functions
Modern Python admits type hints on parameters and return values:
def greet(name: str, age: int = 0) -> str:
return f"{name} is {age}"
def map_strings(strings: list[str], f: Callable[[str], str]) -> list[str]:
return [f(s) for s in strings]
The hints are checked by external tools (mypy, pyright); the runtime ignores them. Treated in Type hints.
Common patterns
Default-value with None sentinel
def process(items=None, options=None):
if items is None:
items = []
if options is None:
options = {}
# ...
The conventional defence against the mutable-default trap.
Variadic forwarding
def with_logging(f):
def wrapped(*args, **kwargs):
log_call(f, args, kwargs)
return f(*args, **kwargs)
return wrapped
The *args, **kwargs admits forwarding any signature; the wrapped function works for any function.
Configurable default
def fetch(url: str, *, timeout: int = 30, retries: int = 3):
# timeout and retries must be passed as keyword
pass
fetch("http://example.com", timeout=60)
The keyword-only enforcement makes the API self-documenting at the call site.
Closure-based state
def make_authenticator(secret_key):
def check(token):
return validate_token(token, secret_key)
return check
verify = make_authenticator("my-secret")
verify("token-123")
The closure captures secret_key; the returned function is parameterised by it.
Generator-based iteration
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
for n in fibonacci():
if n > 1000:
break
print(n)
The yield makes the function a generator; treated in Iterators and generators.
Function as data
def add(x, y): return x + y
def sub(x, y): return x - y
def mul(x, y): return x * y
operations = {"+": add, "-": sub, "*": mul}
def calculate(op, x, y):
return operations[op](x, y)
calculate("+", 2, 3) # 5
calculate("*", 4, 5) # 20
The pattern admits dispatch tables — clearer than long if/elif chains for value-to-operation maps.
Single-dispatch via functools.singledispatch
from functools import singledispatch
@singledispatch
def serialise(value):
raise NotImplementedError(f"no serialiser for {type(value).__name__}")
@serialise.register
def _(value: int):
return str(value)
@serialise.register
def _(value: str):
return f'"{value}"'
@serialise.register
def _(value: list):
return "[" + ",".join(serialise(item) for item in value) + "]"
print(serialise(42)) # '42'
print(serialise("hello")) # '"hello"'
print(serialise([1, 2, 3])) # '[1,2,3]'
The mechanism admits ad-hoc polymorphism — different implementations for different argument types.
A note on the absence of overloading
Python does not have function overloading at the language level; a function name binds to one implementation. The conventional alternatives:
- Default arguments and
*args/**kwargs— handle multiple call shapes in one function. isinstancechecks — dispatch on argument type.functools.singledispatch— type-based dispatch.@overloadintyping— type-checker-only overloading (the runtime sees one function).
from typing import overload
@overload
def process(value: int) -> int: ...
@overload
def process(value: str) -> str: ...
def process(value):
if isinstance(value, int):
return value * 2
return value.upper()
The @overload form admits documenting multiple type signatures for the type checker while the actual implementation is one function.
A note on lambda versus def
The conventional Python style:
- Use
deffor functions with substantive bodies, multiple statements, or names that aid debugging. - Use
lambdafor short callbacks where the body is a single expression and the name would not add clarity. - Avoid
lambdaassignments —add = lambda x, y: x + yis conventionally writtendef add(x, y): return x + y.
The lambda form is principally useful as an inline argument; assignment to a name almost always prefers the def form.