Topic 9.9
Functional Style in Python
In one line
Functional style means building programs from small pure functions that take values and return new values, instead of changing things in place. Python mixes it freely with other styles: map, filter, reduce, comprehensions, operator and immutable data are its tools.
Think of it like this
A calculator versus a notebook you keep rubbing out. A pure function is like a calculator button: press 7 then square, you always get 49, and nothing else in the room changes. A function that changes global state is like a shared notebook where everybody scribbles: the answer depends on who wrote what before you.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Pure function
- Its result depends only on its arguments, and it changes nothing outside itself.
- Side effect
- Anything a function does besides returning a value: printing, writing a file, changing a global or an argument.
- Immutable
- Cannot be changed after it is made. Strings, tuples and frozen dataclasses are immutable.
- Higher-order function
- A function that takes a function as an argument or returns one, such as
mapor a decorator. - map / filter
- Built-ins that transform every item, or keep only some items, lazily.
- lambda
- A small unnamed function written in one expression:
lambda x: x * 2. - operator module
- Ready-made function versions of operators, plus
itemgetterandattrgetter. - In-place
- Changing the existing object instead of making a new one.
Step by step
01Pure versus impure
with_tax_pure(price) always gives the same answer for the same price and touches nothing else. An impure version that also appends to a global list changes the outside world on every call: the same call leaves different traces and is harder to test.
log = []
def with_tax_pure(price):
return round(price * 1.18, 2)
def with_tax_impure(price):
log.append(price) # side effect: changes a global
return round(price * 1.18, 2)02map and filter versus comprehensions
The two forms give the same answer. The comprehension reads left to right like English and needs no list() wrapper, because it builds a list itself. map and filter return lazy iterators, so you wrap them in list to see the values.
prices = [10, 25, 40, 5]
print(list(map(with_tax_pure, prices)))
print([with_tax_pure(p) for p in prices])
print(list(filter(lambda p: p > 9, prices)))
print([p for p in prices if p > 9])03reduce versus the built-ins
reduce(lambda a, b: a + b, prices) is just sum(prices), which is shorter, faster and clearer. Keep reduce for combinations that no built-in covers, such as merging a list of dictionaries.
04New values instead of changes
Tuples cannot change, so original + (4,) builds a new tuple and leaves original alone. sorted(scores) returns a new list; scores.sort() changes the list and returns None, a common trap: scores = scores.sort() leaves scores as None.
05The operator module
itemgetter(1) is the same as lambda row: row[1]; attrgetter("price") is lambda p: p.price; operator.mul is lambda a, b: a * b. They are named, tested, and a little faster. They read well in sorted(rows, key=itemgetter(1)).
06Immutable data with frozen dataclasses
A @dataclass(frozen=True) makes instances read-only: assigning to a field raises FrozenInstanceError. To "change" one, build a new instance with the changed field (dataclasses.replace(pen, price=8) or Product(pen.name, 8)). Immutable data cannot be changed by accident from far away, which removes a whole family of bugs.
07Choosing a style
Use a comprehension for a simple transformation or filter, map/filter with a named function, sum/max/any instead of reduce, a generator pipeline for streams (Topic 9.3), and a loop when you need several statements per item or must keep state. Pick the one a teammate reads fastest.
Try it yourself
- 1
Spot the side effect
Write a function that takes a list and calls
.append(0)on it, and call it twice with the same list. Print the list after each call. The caller's list changes, which is a side effect on an argument. Now rewrite it to returnitems + [0]and compare. - 2
Rewrite a map
Rewrite
list(map(double, [1, 2, 3]))as a comprehension, then asmap(operator.mul, [1, 2, 3], [2, 2, 2]).mapaccepts several iterables and stops at the shortest.
Code & diagrams
Try replacing the lambda in filter with a named function. Note that scores.sort() returns None.
Expected output
map: [11.8, 29.5, 47.2, 5.9]
comprehension: [11.8, 29.5, 47.2, 5.9]
filter: [10, 25, 40]
comprehension: [10, 25, 40]
reduce: 80
sum: 80
(1, 2, 3) (1, 2, 3, 4)
sorted copy: [1, 2, 3] original: [3, 1, 2]
sort in place returns: None now: [1, 2, 3]Sort the products by name instead, with attrgetter('name'). Then try itemgetter(0) on the rows.
Expected output
[Product(name='pen', price=10), Product(name='bag', price=120), Product(name='book', price=250)]
['a', 'bb', 'ccc']
[('Grace', 45), ('Ada', 36), ('Linus', 28)]
operator.add: 10
operator.mul: 24
Product(name='pen', price=10) Product(name='pen', price=8)
FrozenInstanceError
[2, 4, 6]Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Use a map result twice, and reduce an empty list
Loop over a map object twice, and reduce an empty list with no start value.
from functools import reduce
doubled = map(lambda n: n * 2, [1, 2, 3])
print(list(doubled))
print(list(doubled))
print(reduce(lambda a, b: a + b, []))Myth vs fact
Myth
Functional style means never using loops or classes.
Fact
In Python it means preferring pure functions and new values where that helps. Loops, classes and in-place changes are fine where they are clearer.
Myth
map and filter are always more Pythonic than comprehensions.
Fact
Most Python style guides prefer comprehensions for simple cases. map shines with an existing named function.
Myth
Pure functions cannot print or call APIs.
Fact
A function that prints or calls an API has side effects, so it is not pure. The aim is to keep the logic pure and put the printing and calling at the edges of the program.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
CPython has no tail-call optimisation and a default recursion limit of 1000, so recursion is not a replacement for loops. Write iterative code, or use a generator, for anything that may run deep.
- ▸
Immutability is cheap for small data and expensive for large data:
old + [x]copies the whole list. If you build a big list in a loop, appending to a local list that you then return is both fast and still "pure" from the caller's point of view, because no outside state is touched. - ▸
Pure functions are the ones you can cache (
lru_cache), run in parallel processes without locks, and test with plain inputs and outputs. When pipelines call models (non-deterministic, with network side effects), keep the pre- and post-processing pure and isolate the model call behind one small function you can replace with a fake in tests. - ▸
mapandfilterare lazy, so an exception inside the function appears only when you consume the result, andmapwith several iterables silently stops at the shortest (Python 3.14 addsmap(..., strict=True)to raise instead; on 3.13 and earlier usezip(strict=True)first). Becausemapandfilterare lazy objects, alambdathat mentions a loop variable has the same late-binding trap as closures (Topic 5.7):partialor a default argument (lambda x, n=n: ...) captures the value now. - ▸
operator.itemgetterwith several keys returns tuples (itemgetter(1, 0)), which gives a compact multi-key sort. Python's sort is stable, so you can also sort twice (secondary key first, primary key second) for mixed ascending and descending order.
Remember this
- 1
A pure function returns a value that depends only on its arguments, and has no side effects: it does not change its arguments, global variables, files or the screen. Pure functions are easy to test (call with inputs, compare the output), easy to cache (Topic 9.5), and safe to call in any order. Real programs need side effects somewhere (printing, saving, calling APIs); the habit is to keep them at the edges and keep the middle pure.
- 2
Immutability habits go with purity. Instead of changing a list in place, build a new one:
new = old + [x],sorted(old)rather thanold.sort(), a new tuple orfrozendataclass (Topic 8.8) rather than editing fields. Remember thatlist.sort()andlist.append()returnNone, because they change the list in place, whilesorted()returns a new list. - 3
map(f, items)appliesfto each item andfilter(f, items)keeps the items for whichfis true. Both return lazy, one-shot iterators (Topic 9.1).functools.reducefolds items into one value (Topic 9.5). In Python, comprehensions (Topic 4.7) usually express the same ideas more readably:[f(x) for x in items if test(x)]replaces amapplus afilter, andsum(...),max(...),any(...)replace mostreducecalls. - 4
Prefer
mapandfilterwhen you already have a named function (map(str.strip, lines),filter(None, items)to drop falsy values) and a comprehension when you would otherwise write alambda. Alambdais a small anonymous function, limited to one expression (Topic 5.6).map(lambda x: x * 2, items)is longer and slower than[x * 2 for x in items], and harder to read. - 5
The
operatormodule gives functions for operators, so you do not write lambdas:operator.add,operator.mul,operator.itemgetter(1)(get item 1 of a thing) andoperator.attrgetter("price")(get an attribute). They are the neat way to supplykey=forsorted,minandmax, and a function forreduce. Combined withpartial, they build small reusable functions. - 6
Functional style is a tool, not a religion. Some functional languages make recursion as cheap as a loop (tail-call optimisation), but Python does not, so deep recursion in place of loops hits the recursion limit (Topic 5.8). Lists are mutable and fast, and loops are idiomatic. Use pure functions, comprehensions and generator pipelines for transformation; use classes and loops where state is natural. AI data code is a good example: a data-cleaning step is best as a pure function from input rows to output rows, which is simple to test with fixed examples.
Explain it without notes
What is a pure function and what are its benefits?
When would you choose a comprehension over map and filter, and when the reverse?
Why does list.sort() return None, and what is the functional alternative?
What do operator.itemgetter and operator.attrgetter do?
Practice
Write a pure function double_all(numbers) that returns a new list with every number doubled, without changing the input. Show that the input is unchanged.
Sort [("pen", 10), ("bag", 120), ("book", 250)] by the price (second item) from highest to lowest using operator.itemgetter.
Use reduce and operator.mul to compute the product of [2, 3, 4], then do the same with math.prod (a built-in helper that multiplies all the numbers in a list) and compare.
Trade-offs
- ↔
Safety and testability versus copying cost. Building new values avoids hidden changes but copies data. For small data it is a good trade; for huge data build a local list once, or use generators.
- ↔
Concise pipelines versus clarity. Long chains of
map,filterand lambdas become puzzles. Name the steps or use comprehensions and loops where they read better.
Done when you can
Done when you can say whether a function is pure and name its side effects.
Done when you can rewrite a
maporfilteras a comprehension, and the reverse.Done when you can replace a simple
reducewithsum,max,anyorall.Done when you can use
sorted(..., key=itemgetter(...))andattrgetter.Done when you can explain why
sorted()returns a list butlist.sort()returnsNone.