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: 72map + 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 reducenumbers = [1, 2, 3, 4, 5]
total = reduce(lambda acc, n: acc + n, numbers)
print(total) # 15How 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: 15With an initial value:
total = reduce(lambda acc, n: acc + n, numbers, 100)
print(total) # 115 — starts from 100Finding 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) # 9Flattening 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 functoolsfunctools.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)) # 100A 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.000001sWithout 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. — preservedfunctools.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: 95You 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 variablePure 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.00Summary
| Tool | What 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_cache | Cache results of expensive function calls |
cache | Unlimited cache (Python 3.9+) |
wraps | Preserve function identity in decorators |
cached_property | Compute property once, cache on instance |
total_ordering | Fill in comparison methods automatically |