DocsHub
Advanced

Functional Programming

Learn how to use map, filter, reduce, and the functools module to write clean functional-style Python.

Functional Programming

Functional programming is a style of writing code where you treat functions as the main building block. Instead of changing state and looping through data step by step, you describe transformations — what should happen to the data, not how to do it step by step.

Python is not a purely functional language — but it has strong support for functional style through map(), filter(), reduce(), and the functools module.


The core idea

Imperative style — you tell Python exactly how to do it:

numbers = [1, 2, 3, 4, 5]
squared = []

for n in numbers:
    squared.append(n ** 2)

Functional style — you describe what transformation to apply:

numbers = [1, 2, 3, 4, 5]
squared = list(map(lambda n: n ** 2, numbers))

Same result. The functional version expresses the intent — "square every number" — without the boilerplate of an empty list and append.


map() — transform every item

map(function, iterable) applies a function to every item in an iterable and returns a map object (a lazy iterator). Wrap it in list() to get a list.

numbers = [1, 2, 3, 4, 5]

squared = list(map(lambda n: n ** 2, numbers))
print(squared)   # [1, 4, 9, 16, 25]

With a named function:

def celsius_to_fahrenheit(c):
    return (c * 9/5) + 32

temps_celsius = [0, 20, 30, 37, 100]
temps_fahrenheit = list(map(celsius_to_fahrenheit, temps_celsius))
print(temps_fahrenheit)   # [32.0, 68.0, 86.0, 98.6, 212.0]

Map over multiple iterables at once:

a = [1, 2, 3]
b = [10, 20, 30]

result = list(map(lambda x, y: x + y, a, b))
print(result)   # [11, 22, 33]

filter() — keep items that pass a test

filter(function, iterable) keeps only the items where the function returns True:

numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

evens = list(filter(lambda n: n % 2 == 0, numbers))
print(evens)   # [2, 4, 6, 8, 10]

Filter students who passed:

students = [
    {"name": "Ali",    "score": 88},
    {"name": "Sara",   "score": 45},
    {"name": "Omar",   "score": 72},
    {"name": "Fatima", "score": 38},
]

passed = list(filter(lambda s: s["score"] >= 50, students))
for s in passed:
    print(f"{s['name']}: {s['score']}")

Output:

Ali: 88
Omar: 72

map + filter together

Chain them to transform and filter in one pipeline:

orders = [
    {"id": 1, "total": 150, "status": "completed"},
    {"id": 2, "total": 80,  "status": "pending"},
    {"id": 3, "total": 320, "status": "completed"},
    {"id": 4, "total": 45,  "status": "cancelled"},
    {"id": 5, "total": 210, "status": "completed"},
]

# get totals of completed orders with 10% discount applied
discounted = list(map(
    lambda o: round(o["total"] * 0.9, 2),
    filter(lambda o: o["status"] == "completed", orders)
))

print(discounted)   # [135.0, 288.0, 189.0]

In modern Python, list comprehensions are usually preferred over map() and filter() for readability. The same pipeline above as a comprehension:

discounted = [
    round(o["total"] * 0.9, 2)
    for o in orders
    if o["status"] == "completed"
]

Use map() and filter() when working with existing functions you want to pass directly, or when chaining many transformations in a pipeline.


reduce() — collapse a sequence into one value

reduce() applies a function of two arguments cumulatively to a sequence, reducing it to a single value. It lives in functools:

from functools import reduce
numbers = [1, 2, 3, 4, 5]

total = reduce(lambda acc, n: acc + n, numbers)
print(total)   # 15

How it works step by step:

Start:          acc=1,  n=2  →  3
Next:           acc=3,  n=3  →  6
Next:           acc=6,  n=4  →  10
Next:           acc=10, n=5  →  15
Result: 15

With an initial value:

total = reduce(lambda acc, n: acc + n, numbers, 100)
print(total)   # 115 — starts from 100

Finding the maximum without max():

numbers = [3, 1, 4, 1, 5, 9, 2, 6]
maximum = reduce(lambda a, b: a if a > b else b, numbers)
print(maximum)   # 9

Flattening a nested list:

nested = [[1, 2], [3, 4], [5, 6]]
flat = reduce(lambda acc, lst: acc + lst, nested)
print(flat)   # [1, 2, 3, 4, 5, 6]

For simple cases like sum and max, use Python's built-in sum(), max(), min() — they are clearer. Use reduce() when there is no built-in that covers your specific reduction logic.


functools — tools for working with functions

The functools module provides higher-order functions — functions that work on other functions.

import functools

functools.partial — fix some arguments

partial() creates a new function from an existing one with some arguments pre-filled:

from functools import partial

def power(base, exponent):
    return base ** exponent

square = partial(power, exponent=2)
cube   = partial(power, exponent=3)

print(square(4))    # 16
print(cube(3))      # 27
print(square(10))   # 100

A real use — configuring a function for a specific context:

from functools import partial

def send_email(to, subject, body, sender="noreply@myapp.com"):
    print(f"From: {sender}")
    print(f"To: {to}")
    print(f"Subject: {subject}")
    print(f"Body: {body}\n")

# create a pre-configured version for welcome emails
send_welcome = partial(
    send_email,
    subject="Welcome to MyApp!",
    sender="welcome@myapp.com"
)

send_welcome(to="ali@example.com", body="Hi Ali, welcome aboard!")
send_welcome(to="sara@example.com", body="Hi Sara, welcome aboard!")

Output:

From: welcome@myapp.com
To: ali@example.com
Subject: Welcome to MyApp!
Body: Hi Ali, welcome aboard!

From: welcome@myapp.com
To: sara@example.com
Subject: Welcome to MyApp!
Body: Hi Sara, welcome aboard!

functools.lru_cache — cache expensive results

lru_cache (Least Recently Used cache) stores the results of a function call. If you call it again with the same arguments, it returns the cached result instantly — no recomputation:

from functools import lru_cache
import time

@lru_cache(maxsize=128)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

# first call — computes everything
start = time.perf_counter()
print(fibonacci(40))
print(f"First call: {time.perf_counter() - start:.4f}s")

# second call — instant from cache
start = time.perf_counter()
print(fibonacci(40))
print(f"Cached call: {time.perf_counter() - start:.6f}s")

Output:

102334155
First call: 0.0001s
102334155
Cached call: 0.000001s

Without lru_cache, fibonacci(40) makes over 300 million recursive calls. With it, each unique value is computed only once.

maxsize=None caches everything with no limit (also available as @cache in Python 3.9+):

from functools import cache

@cache
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

A real use — caching expensive database or API calls:

from functools import lru_cache

@lru_cache(maxsize=256)
def get_user_permissions(user_id):
    # imagine this hits a database
    print(f"Fetching permissions for user {user_id}...")
    return ["read", "write"]

print(get_user_permissions(42))   # hits the database
print(get_user_permissions(42))   # returns from cache instantly
print(get_user_permissions(99))   # hits the database for new user

# see cache stats
print(get_user_permissions.cache_info())
# CacheInfo(hits=1, misses=2, maxsize=256, currsize=2)

# clear the cache
get_user_permissions.cache_clear()

lru_cache only works with hashable arguments — strings, numbers, tuples. It does not work with lists or dictionaries as arguments because they are mutable and cannot be used as cache keys.


functools.wraps — preserve function identity in decorators

You already saw this in the decorators file. Always use it when writing decorators:

from functools import wraps

def my_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("Before")
        result = func(*args, **kwargs)
        print("After")
        return result
    return wrapper

@my_decorator
def greet(name):
    """Says hello to someone."""
    print(f"Hello, {name}!")

print(greet.__name__)   # greet — preserved
print(greet.__doc__)    # Says hello to someone. — preserved

functools.cached_property — compute once, cache on the instance

cached_property computes a property the first time it is accessed, then caches the result on the instance. Subsequent accesses return the cached value:

from functools import cached_property

class DataAnalysis:
    def __init__(self, data):
        self.data = data

    @cached_property
    def mean(self):
        print("Computing mean...")
        return sum(self.data) / len(self.data)

    @cached_property
    def variance(self):
        print("Computing variance...")
        m = self.mean
        return sum((x - m) ** 2 for x in self.data) / len(self.data)

    @cached_property
    def std_deviation(self):
        import math
        return math.sqrt(self.variance)


analysis = DataAnalysis([4, 8, 15, 16, 23, 42])

print(analysis.mean)           # Computing mean... → 18.0
print(analysis.mean)           # 18.0 — cached, no recomputation
print(analysis.variance)       # Computing variance... → 158.0
print(analysis.std_deviation)  # 12.569...

functools.total_ordering — fill in comparison methods

If you define __eq__ and one of __lt__, __le__, __gt__, __ge__, total_ordering fills in the rest automatically:

from functools import total_ordering

@total_ordering
class Student:
    def __init__(self, name, score):
        self.name = name
        self.score = score

    def __eq__(self, other):
        return self.score == other.score

    def __lt__(self, other):
        return self.score < other.score


students = [
    Student("Ali", 88),
    Student("Sara", 95),
    Student("Omar", 72),
]

best = max(students)
print(best.name)    # Sara

sorted_students = sorted(students)
for s in sorted_students:
    print(f"{s.name}: {s.score}")

Output:

Omar: 72
Ali: 88
Sara: 95

You only wrote two methods — total_ordering gave you all six comparison operators.


Pure functions

A key concept in functional programming — a pure function always returns the same output for the same input and has no side effects (does not modify anything outside itself):

# pure — no side effects, same input always gives same output
def add(a, b):
    return a + b

# impure — modifies external state
total = 0
def add_to_total(n):
    global total
    total += n   # side effect — changes external variable

Pure functions are easier to test, debug, and reason about. Aim for them where possible.


A real pipeline example

Process a list of sales records functionally:

from functools import reduce

sales = [
    {"rep": "Ali",    "region": "North", "amount": 12000, "closed": True},
    {"rep": "Sara",   "region": "South", "amount": 8500,  "closed": True},
    {"rep": "Omar",   "region": "North", "amount": 15000, "closed": False},
    {"rep": "Fatima", "region": "South", "amount": 9200,  "closed": True},
    {"rep": "Zainab", "region": "North", "amount": 11000, "closed": True},
]

# step 1 — keep only closed deals
closed = filter(lambda s: s["closed"], sales)

# step 2 — apply 5% bonus to amounts
with_bonus = map(lambda s: {**s, "amount": s["amount"] * 1.05}, closed)

# step 3 — sort by amount descending
sorted_sales = sorted(with_bonus, key=lambda s: s["amount"], reverse=True)

# step 4 — total revenue
total = reduce(lambda acc, s: acc + s["amount"], sorted_sales, 0)

print(f"Total revenue (with bonus): ${total:,.2f}\n")
for sale in sorted_sales:
    print(f"{sale['rep']:<10} {sale['region']:<8} ${sale['amount']:>10,.2f}")

Output:

Total revenue (with bonus): $42,787.50

Zainab     North     $11,550.00
Ali        North     $12,600.00
Fatima     South      $9,660.00
Sara       South      $8,925.00

Summary

ToolWhat it does
map(fn, iter)Apply function to every item
filter(fn, iter)Keep items where function returns True
reduce(fn, iter)Collapse sequence to one value
partial(fn, ...)Pre-fill some arguments of a function
lru_cacheCache results of expensive function calls
cacheUnlimited cache (Python 3.9+)
wrapsPreserve function identity in decorators
cached_propertyCompute property once, cache on instance
total_orderingFill in comparison methods automatically

On this page