Skip to content

2.2.4 Basics of Functional Programming

Functional data pipeline diagram

This section adds more flexible ways to use functions in Python. lambda, map, filter, the key argument of sorted, and decorators often appear in data processing, framework source code, and utility functions. The goal is to understand and use them moderately, not to chase advanced tricks from the start.

  • Understand the basic ideas of functional programming
  • Master lambda anonymous functions
  • Use the key argument of map(), filter(), and sorted() fluently
  • Understand the basic concepts of closures and decorators

You do not need to aim for “functional is elegant” on the first pass. Just know that it is often used for batch transformation, filtering, sorting, and passing custom logic into frameworks.

Simply put, functional programming means treating functions as data that can be passed around and used.

In Python, functions are first-class citizens — just like numbers and strings, they can:

  • be assigned to variables
  • be passed as arguments to other functions
  • be returned as values
# Functions can be assigned to variables
def greet(name):
return f"Hello, {name}!"
say_hi = greet # Assign the function to a variable (note: no parentheses)
print(say_hi("Xiao Ming")) # Hello, Xiao Ming!
# Functions can be put into a list
def add(a, b): return a + b
def sub(a, b): return a - b
def mul(a, b): return a * b
operations = [add, sub, mul]
for op in operations:
print(op(10, 3)) # 13, 7, 30

lambda is a one-off small function. You do not need def to define it, and it does not need a name.

# Ordinary function
def square(x):
return x ** 2
# Equivalent lambda
square = lambda x: x ** 2
print(square(5)) # 25

Syntax: lambda parameters: expression

# One parameter
double = lambda x: x * 2
print(double(5)) # 10
# Multiple parameters
add = lambda a, b: a + b
print(add(3, 5)) # 8
# With a condition
size_label = lambda hours: "Large" if hours >= 8 else "Small"
print(size_label(12)) # Large
print(size_label(3)) # Small

The most common use of lambda is passing it as an argument to another function:

# Scenario: sort by a specific rule
tasks = [
{"name": "Login API", "hours": 8},
{"name": "RAG demo", "hours": 12},
{"name": "Chart view", "hours": 5},
]
# Sort by estimated hours
tasks.sort(key=lambda task: task["hours"])
print([task["name"] for task in tasks]) # ['Chart view', 'Login API', 'RAG demo']
# Sort by estimated hours in descending order
tasks.sort(key=lambda task: task["hours"], reverse=True)
print([task["name"] for task in tasks]) # ['RAG demo', 'Login API', 'Chart view']

map(): Apply the Same Operation to Each Element

Section titled “map(): Apply the Same Operation to Each Element”

map(function, iterable) applies a function to each element in a sequence and returns a new sequence.

# Square each number in a list
numbers = [1, 2, 3, 4, 5]
# Method 1: use a for loop
squares = []
for n in numbers:
squares.append(n ** 2)
# Method 2: use map
squares = list(map(lambda x: x ** 2, numbers))
print(squares) # [1, 4, 9, 16, 25]
# Method 3: use a list comprehension (usually preferred)
squares = [x ** 2 for x in numbers]
print(squares) # [1, 4, 9, 16, 25]
# Batch convert data types
str_numbers = ["10", "20", "30", "40"]
numbers = list(map(int, str_numbers))
print(numbers) # [10, 20, 30, 40]
# Batch process strings
names = [" alice ", " BOB", "charlie "]
clean_names = list(map(str.strip, names))
print(clean_names) # ['alice', 'BOB', 'charlie']
# Use an existing function
temperatures_c = [0, 20, 37, 100]
def c_to_f(c):
return c * 9/5 + 32
temperatures_f = list(map(c_to_f, temperatures_c))
print(temperatures_f) # [32.0, 68.0, 98.6, 212.0]

filter(): Select Elements That Meet a Condition

Section titled “filter(): Select Elements That Meet a Condition”

filter(function, iterable) keeps the elements for which the function returns True.

numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# Filter even numbers
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens) # [2, 4, 6, 8, 10]
# Equivalent list comprehension
evens = [x for x in numbers if x % 2 == 0]
print(evens) # [2, 4, 6, 8, 10]
# Filter slow responses
latencies_ms = [45, 78, 55, 920, 880, 30, 67, 1000]
slow = list(filter(lambda ms: ms >= 800, latencies_ms))
print(f"Slow responses: {slow}") # [920, 880, 1000]
# Filter non-empty strings
data = ["hello", "", "world", "", "python", ""]
non_empty = list(filter(None, data)) # filter(None, ...) filters out falsy values
print(non_empty) # ['hello', 'world', 'python']
# Filter files of a specific type
files = ["data.csv", "model.py", "readme.md", "train.py", "config.json"]
py_files = list(filter(lambda f: f.endswith(".py"), files))
print(py_files) # ['model.py', 'train.py']

The key argument of sorted() lets you define your own sorting rule:

# Sort by absolute value
numbers = [-5, 3, -1, 4, -2]
result = sorted(numbers, key=abs)
print(result) # [-1, -2, 3, 4, -5]
# Sort by string length
words = ["python", "AI", "deep", "learning"]
result = sorted(words, key=len)
print(result) # ['AI', 'deep', 'python', 'learning']
# Sort by a dictionary key
tasks = [
{"name": "Login API", "owner_count": 2, "hours": 8},
{"name": "RAG demo", "owner_count": 1, "hours": 12},
{"name": "Chart view", "owner_count": 1, "hours": 5},
]
# Sort by estimated hours
by_hours = sorted(tasks, key=lambda task: task["hours"], reverse=True)
for task in by_hours:
print(f"{task['name']}: {task['hours']} hours")
# RAG demo: 12 hours
# Login API: 8 hours
# Chart view: 5 hours
# Sort by multiple conditions (first by priority descending, then by estimated hours ascending if priorities match)
tasks2 = [
{"name": "A", "priority": 2, "hours": 8},
{"name": "B", "priority": 2, "hours": 5},
{"name": "C", "priority": 3, "hours": 12},
]
result = sorted(tasks2, key=lambda task: (-task["priority"], task["hours"]))
for task in result:
print(f"{task['name']}: priority={task['priority']}, hours={task['hours']}")
# C: priority=3, hours=12
# B: priority=2, hours=5
# A: priority=2, hours=8

A closure is a function that remembers variables from its outer function, even after the outer function has finished executing.

def make_multiplier(factor):
"""Create a multiplier"""
def multiplier(x):
return x * factor # factor comes from the outer function
return multiplier
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
print(double(10)) # 20
# Create a counter
def make_counter(start=0):
count = [start] # Wrap it in a list so it can be modified in the inner function
def counter():
count[0] += 1
return count[0]
return counter
counter = make_counter()
print(counter()) # 1
print(counter()) # 2
print(counter()) # 3
# Create a logging function with a prefix
def make_logger(prefix):
def log(message):
from datetime import datetime
timestamp = datetime.now().strftime("%H:%M:%S")
print(f"[{prefix}] {timestamp} {message}")
return log
info = make_logger("INFO")
error = make_logger("ERROR")
info("Program started") # [INFO] 14:30:01 Program started
error("File not found") # [ERROR] 14:30:01 File not found

A decorator is an elegant way to add extra functionality to a function. At its core, it is an application of closures.

Suppose you want to add execution-time statistics to multiple functions:

import time
# Without decorators: each function needs timing code
def train_model():
start = time.time()
# In a real project, this might call a model training loop or API.
epochs = 3
for epoch in range(epochs):
time.sleep(0.25)
print(f"epoch {epoch + 1}/{epochs}: training...")
time.sleep(1)
end = time.time()
print(f"train_model took: {end - start:.2f} seconds")
def process_data():
start = time.time()
# Here we simulate an ETL-style preprocessing step.
records = ["raw-1", "raw-2", "raw-3"]
cleaned = [record.replace("raw", "clean") for record in records]
print("cleaned records:", cleaned)
time.sleep(0.5)
end = time.time()
print(f"process_data took: {end - start:.2f} seconds")

Each function has to repeat the timing code — that is annoying!

import time
def timer(func):
"""Timing decorator"""
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"⏱ {func.__name__} took: {end - start:.2f} seconds")
return result
return wrapper
# Use decorator with @ syntax
@timer
def train_model():
"""Train the model"""
time.sleep(1)
print("Training completed!")
@timer
def process_data(filename):
"""Process data"""
time.sleep(0.5)
print(f"Processing {filename} completed!")
train_model()
# Training completed!
# ⏱ train_model took: 1.00 seconds
process_data("data.csv")
# Processing data.csv completed!
# ⏱ process_data took: 0.50 seconds

@timer is equivalent to train_model = timer(train_model).

# Retry decorator
def retry(max_attempts=3):
def decorator(func):
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"Attempt {attempt} failed: {e}")
if attempt == max_attempts:
raise
return wrapper
return decorator
@retry(max_attempts=3)
def risky_operation():
import random
if random.random() < 0.7:
raise ConnectionError("Connection failed")
return "Success!"

ApproachUse CaseExample
List comprehensionMost cases (recommended)[x**2 for x in nums]
map()When an existing function can be used directlylist(map(int, strings))
filter()When paired with an existing predicate functionlist(filter(str.isdigit, items))
# When you already have a ready-made function, map is simpler
numbers = ["1", "2", "3"]
list(map(int, numbers)) # concise
[int(x) for x in numbers] # also fine, but slightly longer
# When you need transformation + condition, a list comprehension is clearer
[x**2 for x in range(10) if x % 2 == 0]
# Much clearer than list(filter(lambda x: x%2==0, map(lambda x: x**2, range(10))))

# Use map and filter to process the following data
raw_data = [" 23 ", "abc", "45.6", "", "78", "not_a_number", "90.1"]
# 1. Remove whitespace
# 2. Filter out strings that cannot be converted to numbers
# 3. Convert to floating-point numbers
# 4. Filter out numbers less than 50
# Hint: you can combine map, filter, and list comprehensions
products = [
{"name": "laptop", "price": 5999, "rating": 4.5},
{"name": "mouse", "price": 199, "rating": 4.8},
{"name": "keyboard", "price": 599, "rating": 4.2},
{"name": "monitor", "price": 2999, "rating": 4.7},
]
# 1. Sort by price from low to high
# 2. Sort by rating from high to low
# 3. Sort by cost-effectiveness (rating/price) from high to low

Write a @log decorator that prints logs before and after a function runs:

@log
def add(a, b):
return a + b
add(3, 5)
# It should output:
# Calling add, arguments: (3, 5) {}
# add returned: 8
Reference implementation and walkthrough
  1. The pipeline should strip whitespace, drop empty items, keep only strings that can become numbers, convert them, and then keep values >= 50. In the sample data, 78 and 90.1 survive.
  2. Sorting should use three separate sorted(..., key=...) calls: price ascending, rating descending, and a cost-effectiveness score such as rating / price descending.
  3. The decorator should wrap the function, print before/after messages, and return the original result unchanged. In a production answer, functools.wraps should preserve the original metadata.

Keep this page’s proof of learning as a small evidence card:

Pattern
class, exception, file IO, functional pipeline, generator, or type hint
Code Artifact
minimal runnable example and one realistic use case
Output
printed object state, caught error, saved file, yielded values, or type-check note
Failure Check
hidden mutation, swallowed exception, file path issue, lazy iterator confusion, or misleading annotation
Expected Output
small advanced-Python example with a debugging note
ConceptDescriptionExample
lambdaAnonymous functionlambda x: x * 2
map()Apply a function to each elementmap(int, ["1", "2"])
filter()Select elements that meet a conditionfilter(lambda x: x>0, nums)
sorted(key=)Custom sortingsorted(data, key=lambda x: x["hours"])
ClosureFunction remembers outer variablesFactory function pattern
DecoratorAdd extra functionality to a function@timer