Functions#

A function in Python is a first-class object. Define it with def (or lambda for one-expression anonymous forms), assign it to a name, pass it as an argument, return it from another function, store it in a dict, or attach it to a class. The function’s signature is part of its public contract; Python checks it at call time and matches arguments by position, keyword, or both.

This page is the reference for function definition, parameter forms, closures, decorators, lambdas, generators, and the expression-style sister forms (comprehensions, generator expressions). For the language’s structural-pattern dispatch see Control flow. For the typing surface around function signatures see Types.

Definition#

A function is declared with def, has a docstring, accepts arguments, and returns a value. The -> type annotation is optional and not enforced at runtime; it documents intent for the reader and for type checkers (mypy, pyright).

Define total, a variadic function with one keyword-only default.

def total(*nums: int, scale: float = 1.0) -> float:
    """Sum *nums* and multiply by *scale*."""
    return sum(nums) * scale

Call with positional arguments only.

total(1, 2, 3)               # 6.0

Call with the keyword-only scale.

total(1, 2, 3, scale=2)      # 12.0

A function without an explicit return returns None.

Parameters#

Five argument forms compose in one signature.

Form

Behaviour

Positional

Passed by position. def f(a, b):

Default

Optional with a fallback. def f(a, b=10):

Variadic positional

*args captures extra positional args as a tuple.

Keyword

Passed by name. def f(*, a, b): makes them keyword-only.

Variadic keyword

**kwargs captures extra keyword args as a dict.

Two separators tune the rules: / for positional-only, * for keyword-only.

def f(pos1, pos2, /, normal, *, kw_only):
    ...

Variadic capture pulls every positional and keyword argument into args and kwargs.

def g(*args, **kwargs):
    print(args, kwargs)

Mutable default trap#

def f(xs=[]): is one of Python’s oldest sharp edges; the default object is constructed once at function-definition time and shared across every call that uses it. Concurrent calls see each other’s writes.

def f(items=[]):
    items.append(1)
    return items

f()                 # [1]
f()                 # [1, 1]   - same list!

Use None as the sentinel and replace inside the body.

def f(items: list[int] | None = None) -> list[int]:
    items = items if items is not None else []
    items.append(1)
    return items

Return#

A function can return a single value, a tuple (the standard multiple-return idiom), or nothing (implicit None).

Return a tuple, unpack at the call site for multi-value return.

def stats(xs):
    return min(xs), max(xs), sum(xs) / len(xs)

lo, hi, avg = stats([1, 2, 3, 4])

Early-return on the failure path, fall through to the success path.

def maybe(x):
    if x < 0:
        return None
    return x ** 0.5

Early returns are idiomatic. Prefer a series of if-condition-return over deep nesting.

Lambdas#

A lambda is an anonymous, single-expression function. Use it for short callbacks; reach for def the moment the body needs a statement.

Bind a lambda to a name.

inc = lambda x: x + 1

Pass a lambda as a sort key; multi-key by returning a tuple.

sorted(words, key=lambda w: (-len(w), w.lower()))

Pass a lambda as a predicate to filter.

list(filter(lambda x: x % 2, range(10)))

Closures#

Inner functions close over names in their enclosing scope. The operator reads the closed-over names with regular access; rebinds require nonlocal.

def counter():
    n = 0
    def step():
        nonlocal n
        n += 1
        return n
    return step

tick = counter()
tick(); tick(); tick()        # 1, 2, 3

Closures are the operator’s substrate for callbacks, partial application, and decorators.

Decorators#

A decorator wraps a function or class. The @dec syntax above a def is sugar for f = dec(f). Decorators run at definition time, not call time.

import time
from functools import wraps

def timed(f):
    @wraps(f)                 # copy __name__, __doc__, etc.
    def wrapper(*args, **kwargs):
        t0 = time.perf_counter()
        result = f(*args, **kwargs)
        print(f"{f.__name__}: {time.perf_counter() - t0:.3f}s")
        return result
    return wrapper

@timed
def slow():
    ...

Parameterised decorators are a decorator factory.

import logging
log = logging.getLogger(__name__)

def retry(times, *, exc=Exception):
    def deco(f):
        @wraps(f)
        def wrapper(*args, **kwargs):
            for attempt in range(times - 1):
                try:
                    return f(*args, **kwargs)
                except exc as e:
                    log.warning("%s failed (attempt %d): %s",
                                f.__name__, attempt + 1, e)
            return f(*args, **kwargs)
        return wrapper
    return deco

@retry(times=3, exc=ConnectionError)
def flaky(): ...

Stacking decorators#

Decorators stack bottom-up; the bottom decorator is applied first.

@app.route("/api/scan")
@login_required
@timed
def scan():
    ...

The same call expressed without @ sugar.

scan = app.route("/api/scan")(login_required(timed(scan)))

Class decorators#

Decorators on classes work the same way; they receive and return the class. Useful for plugin registration and frame-walking auto-discovery.

def register(cls):
    PLUGINS[cls.__name__] = cls
    return cls

@register
class HttpScanner:
    ...

Common library decorators#

Decorator

Effect

@property

Computed attribute on a class.

@staticmethod

Method that does not receive self or cls.

@classmethod

Method that receives cls instead of self.

@functools.cache

Unbounded memoisation by argument tuple (3.9+).

@functools.lru_cache(maxsize=N)

Bounded LRU memoisation.

@functools.wraps

Used inside decorator factories to preserve identity.

@dataclasses.dataclass

Auto-generate __init__ / __repr__ / __eq__.

@contextlib.contextmanager

Turn a generator into a context manager.

@contextlib.asynccontextmanager

Async variant of the above.

See OOP for @property, @staticmethod, @classmethod, and @dataclass in context; Errors for @contextmanager.

Generators#

A generator is a function that yield s, producing an iterator without implementing the __iter__ / __next__ protocol by hand. Lazy: nothing happens until the consumer pulls the next value.

def first_n_primes(n):
    count = 0
    k = 2
    while count < n:
        if is_prime(k):
            yield k
            count += 1
        k += 1

for p in first_n_primes(10):
    print(p)

Generators preserve local state between yields, which makes them the right tool for streaming, pipelines, and “produce items only as needed” problems.

yield from delegates to another iterable.

def flatten(nested):
    for x in nested:
        if isinstance(x, list):
            yield from flatten(x)
        else:
            yield x

list(flatten([1, [2, [3, 4]], 5]))    # [1, 2, 3, 4, 5]

Generators are bi-directional through .send() / .throw() / .close() but the operator rarely needs those outside coroutine plumbing.

Generator expressions#

Lazy, like a list comprehension but with () and no intermediate list. The natural input to any function that takes an iterable.

Pass a generator expression directly into a consumer like sum.

total = sum(x * x for x in numbers)

Use with any / all for short-circuited tests.

if any(line.startswith("ERROR") for line in lines):
    alert()

itertools covers the standard lazy combinators (chain, islice, groupby, product, combinations, accumulate, count, cycle, repeat).

Comprehensions#

List, dict, set, and generator comprehensions transform iterables inline. The form is uniform across all four.

List comprehension; the workhorse form.

squares = [x * x for x in range(10)]

Filter inside the comprehension.

evens = [x for x in xs if x % 2 == 0]

Pair every element with its index using enumerate.

pairs = [(i, c) for i, c in enumerate(text)]

Dict comprehension from a key-deriving function.

word_lengths = {w: len(w) for w in words}

Set comprehension to deduplicate.

unique = {x for x in xs}

Generator expression; lazy and constant-memory.

stream = (x * 2 for x in numbers)

Reading order. Inside a comprehension, for clauses read top-to-bottom (outer-most first); the leading expression is evaluated last. Three nested levels is the comfort ceiling; past that, prefer a generator function for readability.

matrix = [[1, 2], [3, 4], [5, 6]]
flat = [x for row in matrix for x in row]

Higher-order functions#

Functions are objects; assign them to names, store them in dicts, return them from other functions. map, filter, and sorted are the built-in trio for working with them; functools adds the heavy hitters.

handlers = {
    "get":    lambda req: ...,
    "post":   lambda req: ...,
    "delete": lambda req: ...,
}
response = handlers[req.method.lower()](req)

map applies a function to every element; the comprehension form is usually clearer.

doubled = list(map(lambda x: x * 2, xs))

filter keeps elements where the predicate is truthy.

evens = list(filter(lambda x: x % 2 == 0, xs))

functools.reduce folds an iterable into one value; reach for sum / min / max / math.prod first when those apply.

from functools import reduce
total = reduce(lambda a, b: a + b, xs, 0)

functools.partial binds arguments now, leaves the rest for later.

from functools import partial
say = partial(print, sep=" | ", end="\n")
say("a", "b", "c")             # a | b | c

functools.cache memoises a pure function by its argument tuple.

from functools import cache

@cache
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

Most map / filter calls read better as a comprehension; reach for them only when the function is already a name.

Pure functions and immutability#

Functions that depend only on their arguments and produce no side effects are easier to test, parallelise, and reason about. Prefer immutable types (tuple, frozenset, frozen dataclasses) when the value should not change after creation.

from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class Point:
    x: float
    y: float

    def translate(self, dx, dy):
        return Point(self.x + dx, self.y + dy)

Recursion#

Python supports recursion but does not tail-call optimise. Default recursion limit is 1000 (sys.getrecursionlimit()); deep recursion blows the stack with RecursionError. Convert to iteration or use an explicit stack when depth is unbounded.

Recursive walk over a tree.

def depth(tree):
    if not tree.children:
        return 1
    return 1 + max(depth(c) for c in tree.children)

Bump the recursion limit when the operator knows the depth is bounded but exceeds 1000.

import sys
sys.setrecursionlimit(5000)

References#

  • Syntax for the def / lambda / yield / return keywords this page builds on.

  • Control flow for the loop forms generators replace.

  • OOP for @staticmethod / @classmethod / @property and how methods extend the function model.

  • Types for parameter and return annotations.

  • Errors for the @contextmanager decorator and exception flow inside generators.

  • Libraries for functools and itertools.

  • PEP 8, the style guide (naming, signatures, line length).