Python is not a functional language, but 3.10 through 3.12 added enough that a functional style is now a real option rather than a strained one. This is what that looks like in code, and where it stops working.
Everything below runs on 3.12. match and the | union syntax need 3.10+; the itertools.batched examples need 3.12.
$ python3 --versionPython 3.12.4
Immutable values
A frozen dataclass is a product type with structural equality and hashing, generated from the field list.
from dataclasses import dataclass@dataclass(frozen=True, slots=True)class Point:x: floaty: float>>> Point(1, 2) == Point(1, 2)True>>> {Point(1, 2), Point(1, 2)} # hashable, so this works{Point(x=1, y=2)}
frozen actually enforces immutability at runtime, unlike a plain dataclass or a NamedTuple field reassignment attempt.
>>> p = Point(1, 2)>>> p.x = 99Traceback (most recent call last):dataclasses.FrozenInstanceError: cannot assign to field 'x'
Updates go through replace, which returns a new instance and leaves the original untouched.
from dataclasses import replace>>> p1 = Point(1, 2)>>> p2 = replace(p1, x=99)>>> p1, p2(Point(x=1, y=2), Point(x=99, y=2))
Nested immutability needs nested frozen types, plus a container that will not let you mutate in place. tuple over list; MappingProxyType or a frozen dict-like over dict.
from types import MappingProxyType@dataclass(frozen=True, slots=True)class Config:hosts: tuple[str, ...]ports: MappingProxyType[str, int]>>> c = Config(hosts=("a", "b"), ports=MappingProxyType({"http": 80}))>>> c.ports["http"] = 8080TypeError: 'mappingproxy' object does not support item assignment
Validation belongs in __post_init__. Because the instance is frozen, even __post_init__ has to go through object.__setattr__ to set a derived field.
@dataclass(frozen=True, slots=True)class Range:low: inthigh: intdef __post_init__(self):if self.low > self.high:raise ValueError(f"{self.low} > {self.high}")object.__setattr__(self, "span", self.high - self.low)
Sum types
A closed sum, as a union of frozen dataclasses. There is no enum-with-payload in the language; this is the idiomatic substitute.
from dataclasses import dataclass@dataclass(frozen=True, slots=True)class Circle:r: float@dataclass(frozen=True, slots=True)class Rect:w: floath: float@dataclass(frozen=True, slots=True)class Tri:b: floath: floattype Shape = Circle | Rect | Tri # PEP 695, 3.12
match with class patterns destructures each variant. This is the closest thing Python has to pattern matching over an algebraic type.
import mathdef area(s: Shape) -> float:match s:case Circle(r=r):return math.pi * r * rcase Rect(w=w, h=h):return w * hcase Tri(b=b, h=h):return 0.5 * b * h
Positional patterns work when the dataclass declares __match_args__, which dataclasses generate automatically from the field order.
>>> Circle.__match_args__('r',)def area(s: Shape) -> float:match s:case Circle(r):return math.pi * r * rcase Rect(w, h):return w * hcase Tri(b, h):return 0.5 * b * h
There is no exhaustiveness check. A static checker can flag a missing case if the union is a type alias and every branch narrows it, but nothing stops you shipping code that omits one.
def area(s: Shape) -> float:match s:case Circle(r):return math.pi * r * rcase Rect(w, h):return w * h# Tri unhandled: falls through, area() returns None
Guard against that by matching an explicit wildcard that raises, so a missing case fails loudly instead of returning None.
def area(s: Shape) -> float:match s:case Circle(r):return math.pi * r * rcase Rect(w, h):return w * hcase Tri(b, h):return 0.5 * b * hcase _:raise AssertionError(f"unhandled: {s!r}")
Nested patterns, guards, and matching on sequences and mappings in the same statement.
def describe(event: object) -> str:match event:case {"type": "click", "x": x, "y": y} if x < 0 or y < 0:return "click out of bounds"case {"type": "click", "x": x, "y": y}:return f"click at ({x}, {y})"case {"type": "key", "code": ("ctrl", key)}:return f"ctrl+{key}"case [first, *rest] if rest:return f"batch starting with {first}, {len(rest)} more"case _:return "unknown"
optional and result, as types
X | None is the idiomatic optional. mypy and pyright both narrow it after an is None / is not None check.
def find(xs: list[int], target: int) -> int | None:for i, x in enumerate(xs):if x == target:return ireturn Nonedef report(xs: list[int], target: int) -> str:i = find(xs, target)if i is None:return "not found"return f"found at {i}" # i: int here, narrowed
A Result type, built the same way as the shape example above: a closed sum over two frozen dataclasses, generic in the payload and the error.
from dataclasses import dataclassfrom typing import Generic, TypeVarT = TypeVar("T")E = TypeVar("E")@dataclass(frozen=True, slots=True)class Ok(Generic[T]):value: T@dataclass(frozen=True, slots=True)class Err(Generic[E]):error: Etype Result[T, E] = Ok[T] | Err[E]
Chaining without exceptions. Each step returns a Result, and match decides whether to continue or short-circuit.
def parse_int(s: str) -> Result[int, str]:try:return Ok(int(s))except ValueError:return Err(f"not an int: {s!r}")def positive(n: int) -> Result[int, str]:return Ok(n) if n > 0 else Err(f"not positive: {n}")def and_then[T, U, E](r: Result[T, E], f) -> Result[U, E]:match r:case Ok(value):return f(value)case Err(_):return r>>> and_then(parse_int("42"), positive)Ok(value=42)>>> and_then(parse_int("-5"), positive)Err(error='not positive: -5')>>> and_then(parse_int("x"), positive)Err(error="not an int: 'x'")
Lazy pipelines with itertools
Generator expressions and itertools compose without building intermediate lists, the same way a range pipeline does elsewhere. Nothing runs until you iterate.
from itertools import islicedef squares_of_evens(xs):return (n * n for n in xs if n % 2 == 0)>>> list(islice(squares_of_evens(range(1, 1000000)), 5))[4, 16, 36, 64, 100]# the other 999,995 elements were never touched
An infinite generator, made finite downstream. count() never terminates on its own.
from itertools import count, islicedef naturals():yield from count(0)def fibs():a, b = 0, 1while True:yield aa, b = b, a + b>>> list(islice((n for n in fibs() if n % 2 == 0), 10))[0, 2, 8, 34, 144, 610, 2584, 10946, 46368, 196418]
The itertools worth knowing by name, grouped by what they do.
chain, chain.from_iterable concatenate iterablesgroupby group consecutive equal keysislice slice a lazily, no materializationtakewhile, dropwhile stop, or start, on a predicatetee split one iterator into independent copiespairwise (x0,x1), (x1,x2), ...batched fixed-size chunks, 3.12+product, permutations, combinations the combinatorial trio
groupby only groups consecutive runs, which is the thing people trip over. Sort by the key first if the input is not already grouped.
from itertools import groupbywords = ["ant", "bee", "bear", "cat", "cow", "ant"]# wrong: "ant" appears in two different groups>>> [(k, list(g)) for k, g in groupby(words, key=lambda w: w[0])][('a', ['ant']), ('b', ['bee', 'bear']), ('c', ['cat', 'cow']), ('a', ['ant'])]# right: sort by the key first>>> [(k, list(g)) for k, g in groupby(sorted(words), key=lambda w: w[0])][('a', ['ant', 'ant']), ('b', ['bear', 'bee']), ('c', ['cat', 'cow'])]
pairwise and batched, both 3.10+ and 3.12 respectively, replace loops that used to track an index by hand.
from itertools import pairwise, batched>>> list(pairwise([1, 2, 3, 4]))[(1, 2), (2, 3), (3, 4)]>>> list(batched(range(10), 3))[(0, 1, 2), (3, 4, 5), (6, 7, 8), (9,)]
A realistic pipeline: read lines lazily, parse, filter, group, all without holding the file in memory.
from itertools import groupbyfrom dataclasses import dataclass@dataclass(frozen=True, slots=True)class LogLine:level: strmessage: strdef parse_line(line: str) -> LogLine | None:parts = line.rstrip("\n").split(" ", 1)if len(parts) != 2:return Nonelevel, message = partsreturn LogLine(level, message)def errors_by_run(path: str):with open(path) as f:lines = (parse_line(l) for l in f)parsed = (l for l in lines if l is not None)errors = (l for l in parsed if l.level == "ERROR")yield from groupby(errors, key=lambda l: l.message.split(":")[0])
functools: reduce, partial, cache
reduce is fold_left. Python deliberately kept it out of builtins; Guido's own argument was that a named loop reads better for anything nontrivial, which is worth taking seriously rather than reaching for reduce by reflex.
from functools import reduceimport operator>>> reduce(operator.add, [1, 2, 3, 4], 0)10>>> reduce(operator.mul, range(1, 6), 1) # factorial120# the loop, which most reviewers will prefer for anything with a body:total = 0for x in [1, 2, 3, 4]:total += x
partial fixes leading arguments, and the result is picklable in a way a lambda closing over the same values is not, which matters the moment it needs to cross a multiprocessing.Pool boundary.
from functools import partialdef power(base: float, exponent: float) -> float:return base ** exponentsquare = partial(power, exponent=2)cube = partial(power, exponent=3)>>> square(5), cube(5)(25, 125)from multiprocessing import Pool>>> with Pool() as p:... p.map(square, range(5))[0, 1, 4, 9, 16]
cache and lru_cache memoize on argument identity. Arguments must be hashable, which is another reason frozen dataclasses and tuples earn their keep.
from functools import cache@cachedef fib(n: int) -> int:return n if n < 2 else fib(n - 1) + fib(n - 2)>>> fib(80)23416728348467685>>> fib.cache_info()CacheInfo(hits=78, misses=81, maxsize=None, currsize=81)
reduce composed with a pipeline: fold over a lazily filtered, transformed range without ever materializing it.
from functools import reduceimport operator>>> reduce(... operator.add,... (n * n for n in range(1, 1000000) if n % 2 == 0),... 0,... )333332833333000000
Composition and higher-order functions
Python has no built-in compose. Writing one is a few lines, and reduce is the natural tool for folding over an arbitrary number of functions.
from functools import reducefrom typing import Callabledef compose(*fns: Callable) -> Callable:def composed(x):return reduce(lambda acc, f: f(acc), reversed(fns), x)return composedshout = compose(str.upper, lambda s: s + "!")>>> shout("hello")'HELLO!'
Pipe order, which reads left to right and is usually the more natural direction for a data pipeline.
def pipe(*fns: Callable) -> Callable:def piped(x):return reduce(lambda acc, f: f(acc), fns, x)return pipedprocess = pipe(str.strip, str.lower, lambda s: s.replace(" ", "_"))>>> process(" Hello World ")'hello_world'
Decorators are function composition with syntax. A decorator that takes arguments is a function returning a function returning a function, which is worth writing out once to stop it feeling magic.
import timefrom functools import wrapsdef retry(times: int, delay: float = 0.1):def decorator(fn):@wraps(fn)def wrapper(*args, **kwargs):for attempt in range(times):try:return fn(*args, **kwargs)except Exception:if attempt == times - 1:raisetime.sleep(delay)return wrapperreturn decorator@retry(times=3)def flaky_call():...
Structural recursion, and where it breaks
A recursive walk over a tree, the way you would write it anywhere else.
@dataclass(frozen=True, slots=True)class Leaf:value: int@dataclass(frozen=True, slots=True)class Node:left: "Tree"right: "Tree"type Tree = Leaf | Nodedef total(t: Tree) -> int:match t:case Leaf(value):return valuecase Node(left, right):return total(left) + total(right)
Python has no tail-call optimization, and CPython's default recursion limit is 1000. A deep or unbalanced tree, or a naive recursive fold over a long list, hits it fast.
>>> import sys; sys.getrecursionlimit()1000>>> def count_down(n): return 0 if n == 0 else count_down(n - 1)>>> count_down(2000)Traceback (most recent call last):RecursionError: maximum recursion depth exceeded
Raising the limit moves the wall, it does not remove it; the real bound is the C stack, and past a point this segfaults the interpreter instead of raising.
import syssys.setrecursionlimit(100_000)# fixes small overruns, still eventually crashes the process
The fix is the same one every non-tail-call language needs: rewrite as an explicit loop with your own stack.
def total_iterative(t: Tree) -> int:stack = [t]acc = 0while stack:node = stack.pop()match node:case Leaf(value):acc += valuecase Node(left, right):stack.append(left)stack.append(right)return acc
Pattern matching a real payload
A worked example that puts most of the above together: decoding a JSON-shaped command, structurally, with no isinstance chain.
def handle(command: dict) -> Result[str, str]:match command:case {"op": "move", "dx": int(dx), "dy": int(dy)}:return Ok(f"move by ({dx}, {dy})")case {"op": "rotate", "degrees": (int() | float()) as deg}:return Ok(f"rotate {deg} degrees")case {"op": "batch", "commands": [*cmds]} if cmds:results = [handle(c) for c in cmds]if all(isinstance(r, Ok) for r in results):return Ok(f"batch of {len(cmds)} ok")return Err("batch had a failing command")case {"op": str(op)}:return Err(f"unknown op: {op}")case _:return Err("malformed command")>>> handle({"op": "move", "dx": 1, "dy": -1})Ok(value='move by (1, -1)')>>> handle({"op": "batch", "commands": [{"op": "move", "dx": 1, "dy": 1}]})Ok(value='batch of 1 ok')>>> handle({"op": "teleport"})Err(error='unknown op: teleport')