Function composition: building complex behavior from small, simple pieces
Once functions are values that can be passed around (lesson 3), a natural next question appears: can you build a new function by chaining several small ones together, the way you'd chain mathematical operations? The answer is yes, and it's one of functional programming's most practically useful ideas.
Intermediate
3 min read
Composing two functions by hand
def trim(s): return s.strip()def lowercase(s): return s.lower()def normalize(s): return lowercase(trim(s)) # manually chaining two pure functionsnormalize(" Hello World ") # "hello world"
Function composition means building a new function by feeding one function's output directly into another's input — normalize here is just lowercase applied to whatever trim returns. Each individual piece (trim, lowercase) stays small, focused, and independently testable; normalize itself becomes almost too simple to need its own dedicated test, since it's just gluing two already-tested pieces together.
A generic compose helper: automating the chaining
def compose(*functions): def composed(x): result = x for f in reversed(functions): result = f(result) return result return composednormalize = compose(lowercase, trim)normalize(" Hello World ") # "hello world" — same result, no manual chaining
A generic compose higher-order function (lesson 3's concept, applied directly) takes any number of functions and returns a single new function that runs them in sequence — each function's output becomes the next one's input, right to left by mathematical convention (mirroring how f(g(x)) is read). This is the same underlying idea as piping data through a series of transformations, conceptually close to chaining .map() calls or, outside code entirely, chaining shell commands with | (Linux & the Command Line's own domain covered piping directly) — in both cases, small, single-purpose pieces combine into a larger pipeline.
Why small, composed pieces are easier to reason about than one large function
# One large function — every concern tangled togetherdef process(text): text = text.strip() text = text.lower() words = text.split() words = [w for w in words if len(w) > 2] return " ".join(words)# Composed from named, individually-testable piecesclean_text = compose(lowercase, trim)remove_short_words = lambda words: [w for w in words if len(w) > 2]
A single large function mixes several genuinely separate concerns (trimming whitespace, normalizing case, filtering short words) into one block that can only be tested, read, and modified as a whole. Breaking it into composed, named pieces means each piece can be understood, tested, and reused entirely on its own — directly mirroring the single-responsibility reasoning Software Engineering Fundamentals' own domain applied to functions and classes generally, just expressed here through composition instead of object structure.
Composition's real limit: it works best when every step is pure
Composing functions with side effects (one writes to a database, the next reads from a different file) still technically works mechanically, but loses most of composition's actual benefit — the resulting pipeline's behavior now depends on the order things happen in the real world, not just the order of function calls, and testing any one piece in isolation again requires mocking whatever external thing it touches. Composition is at its most powerful specifically with pure functions (lesson 1), which is exactly why functional programming treats purity as foundational rather than as one nice-to-have property among many.