Command Palette

Search for a command to run...

Hectal
PHASE 9Intermediate ~26 min· topic 9 of 9

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 map or 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 itemgetter and attrgetter.
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.

main.pywhole filepython
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.

main.pywhole filepython
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)).

The operator modulediagram
Rendering diagram…

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. 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 return items + [0] and compare.

  2. 2

    Rewrite a map

    Rewrite list(map(double, [1, 2, 3])) as a comprehension, then as map(operator.mul, [1, 2, 3], [2, 2, 2]). map accepts several iterables and stops at the shortest.

Code & diagrams

Pure functions, map, filter, reduce New tab

Try replacing the lambda in filter with a named function. Note that scores.sort() returns None.

Sign in to run this example in your browser.

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]
operator, frozen data and partial New tab

Sort the products by name instead, with attrgetter('name'). Then try itemgetter(0) on the rows.

Sign in to run this example in your browser.

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.

main.pywhole filepython
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, []))
terminal
$ python main.py
── what you'll see ──
[2, 4, 6]
[]
Traceback (most recent call last):
File "main.py", line 7, in <module>
print(reduce(lambda a, b: a + b, []))
~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: reduce() of empty iterable with no initial value

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.

  • ▸

    map and filter are lazy, so an exception inside the function appears only when you consume the result, and map with several iterables silently stops at the shortest (Python 3.14 adds map(..., strict=True) to raise instead; on 3.13 and earlier use zip(strict=True) first). Because map and filter are lazy objects, a lambda that mentions a loop variable has the same late-binding trap as closures (Topic 5.7): partial or a default argument (lambda x, n=n: ...) captures the value now.

  • ▸

    operator.itemgetter with 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. 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. 2

    Immutability habits go with purity. Instead of changing a list in place, build a new one: new = old + [x], sorted(old) rather than old.sort(), a new tuple or frozen dataclass (Topic 8.8) rather than editing fields. Remember that list.sort() and list.append() return None, because they change the list in place, while sorted() returns a new list.

  3. 3

    map(f, items) applies f to each item and filter(f, items) keeps the items for which f is true. Both return lazy, one-shot iterators (Topic 9.1). functools.reduce folds 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 a map plus a filter, and sum(...), max(...), any(...) replace most reduce calls.

  4. 4

    Prefer map and filter when 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 a lambda. A lambda is 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. 5

    The operator module gives functions for operators, so you do not write lambdas: operator.add, operator.mul, operator.itemgetter(1) (get item 1 of a thing) and operator.attrgetter("price") (get an attribute). They are the neat way to supply key= for sorted, min and max, and a function for reduce. Combined with partial, they build small reusable functions.

  6. 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

01

What is a pure function and what are its benefits?

02

When would you choose a comprehension over map and filter, and when the reverse?

03

Why does list.sort() return None, and what is the functional alternative?

04

What do operator.itemgetter and operator.attrgetter do?

Practice

01

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.

02

Sort [("pen", 10), ("bag", 120), ("book", 250)] by the price (second item) from highest to lowest using operator.itemgetter.

03

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, filter and 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 map or filter as a comprehension, and the reverse.

  • Done when you can replace a simple reduce with sum, max, any or all.

  • Done when you can use sorted(..., key=itemgetter(...)) and attrgetter.

  • Done when you can explain why sorted() returns a list but list.sort() returns None.