Skip to content

Python Functions

Functions are the most important code-organization unit in Python. This article covers function definitions, parameter types, scope, closures, and modern type annotation usage.

Function Basics

Definition and Calling

def greet(name: str) -> str:
    """Return a greeting message."""
    return f"Hello, {name}!"

print(greet("Alice"))   # Hello, Alice!

A function body consists of the def keyword, the function name, a parameter list, a colon, the function body, and an optional return statement.

  • A function with no return, or just return, returns None.
  • Use """...""" to write a docstring; view it with help(greet).

Multiple Return Values

def min_max(lst: list[int]) -> tuple[int, int]:
    return min(lst), max(lst)

lo, hi = min_max([3, 1, 4, 1, 5, 9])
print(lo, hi)   # 1 9

Python actually returns a tuple; the call site unpacks it.

Parameter Types

Positional and Keyword Arguments

def register(name: str, age: int, city: str = "Beijing") -> None:
    print(f"{name}, {age} years old, from {city}")

register("Alice", 25)                 # By position
register("Bob", age=30, city="Shanghai")  # Keyword arguments can reorder
register("Carol", 22, "Guangzhou")

Variable Positional Arguments *args

def add(*numbers: int) -> int:
    return sum(numbers)

print(add(1, 2, 3, 4))   # 10

# Unpack a sequence at call time with *
nums = [1, 2, 3]
print(add(*nums))         # 6

Variable Keyword Arguments **kwargs

def build_url(host: str, **params: str) -> str:
    query = "&".join(f"{k}={v}" for k, v in params.items())
    return f"{host}?{query}" if query else host

print(build_url("example.com", page="1", sort="name"))
# example.com?page=1&sort=name

# Unpack a dictionary at call time with **
options = {"page": "2", "size": "20"}
print(build_url("api.example.com", **options))

Parameter Order Rules

Full parameter order: positionaldefault*argskeyword-only**kwargs

def func(pos1, pos2, default="x", *args, kw_only, **kwargs):
    print(pos1, pos2, default, args, kw_only, kwargs)

func(1, 2, "y", 3, 4, kw_only="z", extra=99)
# 1 2 y (3, 4) z {'extra': 99}

Positional-Only Parameters / (Python 3.8+)

def circle_area(radius: float, /, *, precision: int = 2) -> float:
    import math
    return round(math.pi * radius ** 2, precision)

circle_area(5)              # radius can only be passed positionally
circle_area(5, precision=4) # precision can only be passed as a keyword
# circle_area(radius=5)    # TypeError

Parameters to the left of / are positional-only; parameters to the right of * are keyword-only.

Scope (LEGB Rule)

Python resolves names in the order Local → Enclosing → Global → Built-in:

x = "global"

def outer():
    x = "enclosing"

    def inner():
        x = "local"
        print(x)   # local

    inner()
    print(x)       # enclosing

outer()
print(x)           # global

global and nonlocal

counter = 0

def increment() -> None:
    global counter          # Declare intent to modify the global variable
    counter += 1

def make_counter():
    count = 0
    def inc():
        nonlocal count      # Declare intent to modify the enclosing function's variable
        count += 1
        return count
    return inc

c = make_counter()
print(c(), c(), c())   # 1 2 3

Closures

A closure is an inner function that references variables (free variables) from an enclosing function. Even after the enclosing function has returned, the free variables remain alive:

def multiplier(factor: int):
    def multiply(x: int) -> int:
        return x * factor   # factor is a free variable
    return multiply

double = multiplier(2)
triple = multiplier(3)

print(double(5))    # 10
print(triple(5))    # 15

# Inspect the free variable
print(double.__closure__[0].cell_contents)   # 2

Common uses: factory functions, deferred computation, stateful objects (a lightweight alternative to classes).

Functions as First-Class Objects

In Python, functions are first-class objects — they can be assigned to variables, passed as arguments, and returned as values:

def apply(func, values: list) -> list:
    return [func(v) for v in values]

print(apply(str.upper, ["hello", "world"]))
# ['HELLO', 'WORLD']

# Store in a dictionary (strategy pattern)
ops = {
    "add": lambda x, y: x + y,
    "sub": lambda x, y: x - y,
    "mul": lambda x, y: x * y,
}
print(ops["add"](3, 4))   # 7

Recursion

A function that calls itself, directly or indirectly, is called recursive. Recursion must have a clear base case (the exit condition), or it will call forever until the stack overflows:

def factorial(n: int) -> int:
    """Compute n! Base case: n <= 1."""
    if n <= 1:
        return 1
    return n * factorial(n - 1)   # Recursive call, shrinking the problem each time

print(factorial(5))   # 120

Fibonacci: Naive Recursion vs. Optimized

A naive recursive implementation is elegant but recomputes the same subproblems over and over, so it slows down noticeably even at moderate sizes:

def fib(n: int) -> int:
    return n if n < 2 else fib(n - 1) + fib(n - 2)

fib(30)   # Already noticeably slow: fib(28) and friends get recomputed countless times

Use functools.lru_cache to memoize results and avoid recomputation:

from functools import lru_cache

@lru_cache(maxsize=None)
def fib_cached(n: int) -> int:
    return n if n < 2 else fib_cached(n - 1) + fib_cached(n - 2)

print(fib_cached(100))   # 354224848179261915075, returns instantly

The same problem can also be rewritten iteratively, which avoids recomputation entirely and isn’t bound by the recursion depth limit:

def fib_iter(n: int) -> int:
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

Recursion Depth Limit

The Python interpreter protects itself with a limit on recursion depth; exceeding it raises RecursionError:

import sys

print(sys.getrecursionlimit())   # 1000 by default
sys.setrecursionlimit(3000)      # Can be raised, but is still bounded by the OS stack size — don't raise it without limit
When to use recursion: it’s expressive and concise, and a natural fit for recursive structures like trees and graphs (see the tree-traversal implementation in the “Data Structures & Algorithms” section). But every call opens a new stack frame — more overhead than a loop — and it’s bounded by the depth limit. Prefer a loop whenever one works; save recursion for problems that would be noticeably more complex to express iteratively.

Type Annotations (Recommended)

Python 3.5+ supports type annotations. Combined with mypy or pyright, they enable static type checking:

from typing import Callable, Optional
from collections.abc import Sequence

def transform(
    data: Sequence[int],
    func: Callable[[int], int],
    default: Optional[int] = None,
) -> list[int]:
    """Apply func to every element in the sequence."""
    return [func(x) for x in data] if data else ([default] if default else [])

# Python 3.10+ shorthand for Union
def parse(value: int | str | None) -> str:
    if value is None:
        return "null"
    return str(value)

Commonly Used Built-in Functions

nums = [3, 1, 4, 1, 5, 9, 2, 6]

# Sorting (returns a new list)
print(sorted(nums))                         # Ascending
print(sorted(nums, reverse=True))           # Descending
print(sorted(["banana", "apple"], key=len)) # Sort by length

# map / filter (return iterators)
doubled = list(map(lambda x: x * 2, nums))
evens = list(filter(lambda x: x % 2 == 0, nums))

# zip (parallel iteration over multiple sequences)
for a, b in zip([1, 2, 3], ["x", "y", "z"]):
    print(a, b)

# enumerate (iterate with index)
for i, v in enumerate(nums, start=1):
    print(f"{i}: {v}")

# any / all
print(any(x > 8 for x in nums))    # True
print(all(x > 0 for x in nums))    # True
Last updated on