Filter concepts by levelShowing all levels.

Python · Functional Programming

Functional Programming

Pure functions, composition, laziness, and the functools toolbox — readable functional Python, not clever Python

  • Pure = no side effectssame input always gives the same output
  • Lazy by defaultmap, filter, and generators compute nothing until pulled
  • reduce folds, compose chainsmany small functions become one transformation
  • Immutable data shares safelytuple, frozenset, frozen dataclass — nothing to edit out from under you

1.Pure functions

A pure function returns a value that depends only on its arguments, and changes nothing outside itself — no global variable, no file, no argument mutated in place. Call it twice with the same input and it gives the same answer, every time.

Key features

  • Same arguments in, same return value out — every single call, forever
  • No side effects: no global write, no file write, no mutating an argument
  • Never depends on hidden state — no reading a global, clock, or random source
  • Easy to test: no setup, no mocking, just call it and check the return value
  • Safe to call from multiple threads at once — nothing shared gets fought over

What makes a function impure

What makes a function impure
Impure behaviorWhy it breaks purity
Reads or writes a global variableOutput depends on, or changes, state outside the call
Mutates a list or dict argumentThe caller sees a side effect, not just a return value
Calls print(), writes a file, hits the networkAffects the outside world beyond the return value
Calls random.random() or datetime.now()Same arguments give a different result on each call
Pure vs impure, same job

Pure

  • Reads only its own arguments
  • Returns a value, changes nothing else
  • Same input always gives the same output

Impure

  • Reads or writes state outside itself
  • A global here accumulates across calls
  • Same input can give a different output
  • Pure
    • Reads only its own arguments
    • Returns a value, changes nothing else
    • Same input always gives the same output
  • Impure
    • Reads or writes state outside itself
    • A global here accumulates across calls
    • Same input can give a different output

Command

python
def add_tax(price, rate):        # pure: output depends only on price and rate
    return price * (1 + rate)

add_tax(100, 0.08)               # 108.0
add_tax(100, 0.08)               # 108.0 — identical call, identical result

A pure function only reads its arguments and only returns a value — no global, no file, no mutated argument. Same input always means same output.

2.Function composition

Composing functions means feeding one function's output straight into the next, building a bigger transformation from small pure ones. Python has no built-in compose() — the pattern is written by hand, usually with a small helper.

Key features

  • compose(f, g)(x) means f(g(x)) — g runs first, f runs on its result
  • Each function should take one input and return one output of a compatible type
  • functools.reduce() folds a list of functions into one composed function
  • Pure functions (pure-functions.js) compose safely — no hidden state to collide
  • Python has no built-in compose() — the pattern is written by hand or via a small library

Composing two functions vs several

Composing two functions vs several
GoalShape
Compose exactly twocompose(f, g) = lambda x: f(g(x))
Compose any number, left to rightreduce(lambda f, g: lambda x: g(f(x)), funcs)
Read as a pipeline insteadvalue = f(g(h(value))) — read right to left

Command

python
def compose(f, g):
    return lambda x: f(g(x))     # g runs first, then f on its result

shout = compose(str.upper, str.strip)
shout("  hello  ")                # 'HELLO'

compose(f, g)(x) runs g first, then f on its result. Python has no built-in compose — reduce() folds any number of functions into one.

3.reduce

functools.reduce(func, iterable, initial) folds an iterable into one value: it calls func(accumulator, item) for each item, left to right, carrying the result forward. Without initial, the first item becomes the starting accumulator.

Key features

  • reduce(func, iterable) — func(accumulator, item), left to right, one final value
  • reduce(func, iterable, initial) — starts the accumulator at initial, not the first item
  • Lives in functools, not a builtin — needs from functools import reduce
  • Not lazy — runs immediately and reads the whole iterable before returning
  • reduce on an empty iterable with no initial raises TypeError

reduce() with and without an initial value

reduce() with and without an initial value
CallResult
reduce(lambda acc, n: acc + n, [1, 2, 3, 4])10 — starts from the first item, 1
reduce(lambda acc, n: acc + n, [1, 2, 3, 4], 100)110 — starts from 100 instead
reduce(lambda acc, n: acc * n, [1, 2, 3, 4], 1)24 — a running product
reduce(lambda acc, n: acc + n, [])TypeError — nothing to start from
reduce(lambda acc, n: acc + n, [], 0)0 — initial covers the empty case
One accumulator, carried through every item

[1, 2, 3, 4]

the iterable, left to right

func(acc, item)

called once per item, accumulator carried forward

10

one final value, not one per item

  1. [1, 2, 3, 4] — the iterable, left to right
  2. func(acc, item) — called once per item, accumulator carried forward
  3. 10 — one final value, not one per item

Command

python
from functools import reduce

reduce(func, iterable)             # func(func(item1, item2), item3), ... — starts from item1
reduce(func, iterable, initial)    # func(initial, item1), then item2, ... — starts from initial

reduce(func, iterable, initial) folds left to right into one value, calling func(acc, item). It always runs eagerly — never lazy, unlike map or filter.

4.functools.partial

functools.partial(func, *args, **kwargs) returns a new callable with those arguments already bound. Calling the result only requires the arguments that were left unbound — the pre-bound ones are filled in automatically every time.

Key features

  • partial(func, *args, **kwargs) pre-binds arguments; calling the result fills in the rest
  • Positional arguments bind left to right, in the order func expects them
  • Keyword arguments bind by name, regardless of position
  • The result has .func, .args, and .keywords attributes exposing what was bound
  • Distinct from a closure: partial builds a new callable object, not a nested function

Binding arguments with partial

Binding arguments with partial
CallResult
partial(power, exponent=2)a new callable — only base is still needed
square(5)25 — 5 supplied as base, exponent=2 already bound
partial(send_request, "GET")GET is bound as the first positional argument, method
get_request("/users")'GET /users timeout=30' — url supplied, method already bound
p.func, p.args, p.keywordsinspect what a partial actually bound
One call becomes two smaller ones

power(base, exponent)

the original, two-argument function

partial(power, exponent=2)

exponent bound now, base still open

square(5)

only base is supplied — 25

  1. power(base, exponent) — the original, two-argument function
  2. partial(power, exponent=2) — exponent bound now, base still open
  3. square(5) — only base is supplied — 25

Command

python
from functools import partial

square = partial(power, exponent=2)   # exponent pre-bound by keyword
square(5)                              # 25 — only base supplied

get_request = partial(send_request, "GET")   # method pre-bound by position
get_request(url, timeout=5)                   # method already filled in

partial(func, *args, **kwargs) pre-binds arguments and returns a new callable; only the unbound arguments are needed at call time.

5.functools

functools is the standard-library module for working with functions themselves — folding with reduce, binding arguments with partial, and caching results. reduce and partial each get their own concept; this is the rest of the module.

Key features

  • reduce(func, iterable, initial) — folds an iterable to one value (its own concept)
  • partial(func, *args, **kwargs) — pre-binds arguments to a new callable (its own concept)
  • @lru_cache(maxsize=...) and @cache — memoize a function's return value by its arguments
  • @wraps(func) — preserves a wrapped function's name and docstring through a decorator
  • @total_ordering — fills in the rest of the comparison methods from just one or two

functools at a glance

functools at a glance
ToolWhat it does
reduce(func, iterable, initial)Folds an iterable into one accumulated value
partial(func, *args, **kwargs)Returns a new callable with some arguments pre-bound
@lru_cache(maxsize=None)Caches return values by argument, evicting the least recently used when full
@cacheCaches every return value forever — an unbounded lru_cache(maxsize=None)
@wraps(func)Copies func's __name__, __doc__, etc. onto a wrapper — keeps decorated functions introspectable

Command

python
from functools import reduce, partial, lru_cache

reduce(lambda acc, n: acc + n, [1, 2, 3])   # fold to one value
double = partial(lambda a, b: a * b, 2)     # pre-bind an argument

@lru_cache(maxsize=None)                    # cache by argument
def slow(n): ...

functools is where Python keeps its function-level tools — reduce, partial, and caching decorators like lru_cache and cache all live here.

6.Lazy evaluation

Lazy evaluation delays computing a value until something actually needs it, instead of computing it the moment the expression is written. map(), filter(), and generators are all lazy — building one does no work; iterating it does.

Key features

  • A lazy object is built instantly — no items are computed at that point
  • Work happens only when something iterates or calls next() on it
  • map(), filter(), and any generator (function or expression) are all lazy
  • A list comprehension is NOT lazy — it computes and stores every item immediately
  • Laziness lets a pipeline handle a huge source without loading it all into memory at once

Lazy vs eager, side by side

Lazy vs eager, side by side
ExpressionLazy?
(x * x for x in nums)yes — a generator expression
map(str.upper, names)yes — nothing runs until consumed
filter(is_valid, rows)yes — same as map
[x * x for x in nums]no — a list comprehension computes everything now
sorted(x for x in nums)the generator is lazy, but sorted() immediately consumes it all
Building vs consuming

Eager — a list comprehension

  • Every item is computed the moment the line runs
  • All results held in memory at once
  • No further work happens on later access

Lazy — a generator expression

  • Nothing computed when the line runs
  • One item computed per next() or iteration step
  • Never holds more than the current item
  • Eager — a list comprehension
    • Every item is computed the moment the line runs
    • All results held in memory at once
    • No further work happens on later access
  • Lazy — a generator expression
    • Nothing computed when the line runs
    • One item computed per next() or iteration step
    • Never holds more than the current item

Command

python
gen = (f(x) for x in source)   # built instantly, computes nothing yet
next(gen)                       # now exactly one item is computed
list(gen)                       # drives the rest, one at a time

A lazy object (map, filter, a generator) does no work when built — only next() or iteration triggers computation, one item at a time.

7.Generator pipelines

A generator pipeline chains several generator functions, each one consuming the previous stage and yielding its own transformed items. Every item flows through every stage one at a time — no stage ever holds the whole dataset.

Key features

  • Each stage is a generator function that takes an iterable and yields transformed items
  • Stages are wired by nesting calls: stage3(stage2(stage1(source)))
  • An item is pulled through every stage before the next item enters stage 1
  • No intermediate list exists between stages — memory stays flat regardless of source length
  • A filtering stage (yields conditionally) mixes freely with one that transforms every item

A four-stage pipeline, each stage in one line

A four-stage pipeline, each stage in one line
StageJob
strip_lines(lines)yields each line.strip()
nonempty(lines)yields only lines that survived strip() non-empty
parse(lines)yields (quantity, name) parsed from each "qty,name" line
positive_only(records)yields only records where quantity > 0
Each stage pulls from the one before it

read_lines()

raw source lines

strip → filter → parse

each stage yields, never collects

positive_only(...)

final stage, pulled by list() or a for loop

  1. read_lines() — raw source lines
  2. strip → filter → parse — each stage yields, never collects
  3. positive_only(...) — final stage, pulled by list() or a for loop

Command

python
def stage_one(source):
    for item in source:
        yield transform(item)

def stage_two(items):
    for item in items:
        if keep(item):
            yield item

pipeline = stage_two(stage_one(raw_source))   # nested calls wire the stages

Nesting generator function calls builds a pipeline where each item flows through every stage before the next item starts — no intermediate list, ever.

8.Immutability concepts

Functional-style Python favors data that cannot change after creation — tuple, frozenset, namedtuple, a frozen dataclass — so a value can be passed and shared freely, with no risk that one piece of code edits it out from under another.

Key features

  • tuple, frozenset, and str are immutable — no method exists that changes them in place
  • A frozen dataclass (@dataclass(frozen=True)) raises on any attempted attribute assignment
  • namedtuple builds an immutable, field-named tuple — attributes cannot be reassigned
  • Immutable data is safe to share across functions or threads with no defensive copying
  • A tuple can still hold a mutable item — nest only tuples to stay immutable throughout

Reaching for an immutable structure

Reaching for an immutable structure
NeedImmutable choice
An ordered sequence that must not changetuple instead of list
A set of members that must not changefrozenset instead of set
A small record with named fieldsnamedtuple, or a frozen dataclass
A dict key or set member (must be hashable)tuple, frozenset — never list, dict, or set

Command

python
from collections import namedtuple
import dataclasses

Point = namedtuple("Point", ["x", "y"])   # immutable, field-named tuple

@dataclasses.dataclass(frozen=True)        # raises on attribute assignment
class Config:
    host: str
    port: int

tuple, frozenset, namedtuple, and a frozen dataclass all refuse mutation — but a tuple holding a list is only immutable at the outer level, not inside.

Remember

  • A pure function only reads its arguments and only returns a value — no global, no file, no mutated argument.
  • reduce(func, iterable, initial) folds left to right and always runs eagerly — never lazy, unlike map or filter.
  • partial(func, *args, **kwargs) pre-binds arguments; only the unbound ones are needed at call time.
  • A lazy object (map, filter, a generator) does no work until next() or iteration actually pulls a value.

Pro tip

  • Reach for sum(), any(), or a comprehension before reduce() — it earns its place only for a genuinely custom fold.
  • Every stage in a generator pipeline must itself be lazy — one eager stage (a list comprehension) forces everything before it to run up front.
  • A tuple holding a list is only immutable at the outer level — nest tuples all the way down for a genuinely frozen structure.
  • Prefer readable Python over unnecessarily clever functional code — the roadmap's own closing note for this section.

Next up

  • Decorators
  • Iterators and generators (the protocol underneath)
  • Object-Oriented Python

Sources

  • functools — docs.python.org/3/library/functools.html
  • Functional Programming HOWTO — docs.python.org/3/howto/functional.html
  • Every example executed on Python 3.14.3 · checked 2026-08-21

Spotted a mistake on this page? Tell us.