Functional Programming Concepts
📖 Concept
Functional programming (FP) in Python is not about abandoning OOP — it is about leveraging functions as the primary unit of abstraction to write code that is easier to test, reason about, and compose. Python is a multi-paradigm language, and its FP support is pragmatic rather than pure, giving you the best of both worlds.
Pure functions are the cornerstone of FP. A pure function: (1) always returns the same output for the same input (referential transparency), and (2) produces no side effects (no mutation of external state, no I/O). Pure functions are trivially testable — you only need to assert inputs against outputs, with no mocking or setup.
Immutability means once a value is created, it cannot be changed. Python offers immutable built-ins (tuple, frozenset, str, bytes) and tools like @dataclass(frozen=True) and NamedTuple for custom immutable objects. Immutability eliminates entire classes of bugs caused by shared mutable state, especially in concurrent programs.
First-class functions mean functions are objects — you can assign them to variables, pass them as arguments, return them from other functions, and store them in data structures. This enables higher-order functions like map, filter, sorted, and custom combinators.
| Concept | Description | Python Example |
|---|---|---|
| Pure function | No side effects, deterministic | def add(a, b): return a + b |
| Higher-order function | Takes/returns functions | map(str.upper, words) |
| Currying | Transform f(a, b) into f(a)(b) | Manual closures or functools.partial |
| Partial application | Fix some arguments, return new function | partial(pow, 2) gives 2**n |
| Composition | Combine functions: (f . g)(x) = f(g(x)) |
compose(f, g)(x) |
| Closure | Inner function captures outer scope | def make_adder(n): return lambda x: x + n |
Currying transforms a function that takes multiple arguments into a chain of functions each taking a single argument: f(a, b, c) becomes f(a)(b)(c). Partial application fixes some arguments and returns a new function that takes the remaining ones. Python's functools.partial is the standard tool for partial application, while currying is typically done with manual closures or third-party libraries.
Function composition chains functions together so the output of one feeds into the input of the next. While Python lacks a built-in composition operator, you can build one with functools.reduce or a simple helper. Composition is the FP equivalent of Unix pipes — small, focused functions chained to solve complex problems.
💻 Code Example
1# ============================================================2# Pure functions vs impure functions3# ============================================================4from typing import Sequence56# IMPURE: modifies external state, non-deterministic7total = 089def impure_add(x: int) -> int:10 global total11 total += x # Side effect: mutates global state12 return total131415# PURE: same input always gives same output, no side effects16def pure_add(a: int, b: int) -> int:17 return a + b181920# PURE: returns new data instead of mutating21def add_item(items: tuple, new_item: str) -> tuple:22 """Return a new tuple with the item appended (immutable)."""23 return items + (new_item,)242526cart = ("apple", "bread")27new_cart = add_item(cart, "milk")28print(cart) # ("apple", "bread") — original unchanged29print(new_cart) # ("apple", "bread", "milk")303132# ============================================================33# Immutability with frozen dataclasses and NamedTuple34# ============================================================35from dataclasses import dataclass, replace36from typing import NamedTuple373839@dataclass(frozen=True)40class Money:41 amount: int # cents to avoid float issues42 currency: str4344 def add(self, other: "Money") -> "Money":45 if self.currency != other.currency:46 raise ValueError(f"Cannot add {self.currency} and {other.currency}")47 return Money(self.amount + other.amount, self.currency)4849 def multiply(self, factor: int) -> "Money":50 return Money(self.amount * factor, self.currency)515253price = Money(1999, "USD") # $19.9954tax = Money(160, "USD") # $1.6055total_price = price.add(tax)56print(total_price) # Money(amount=2159, currency='USD')5758# price.amount = 0 # FrozenInstanceError!59updated = replace(price, amount=2499) # Create modified copy60print(updated) # Money(amount=2499, currency='USD')616263class Point(NamedTuple):64 x: float65 y: float6667 def distance_to(self, other: "Point") -> float:68 return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5697071p1 = Point(3.0, 4.0)72p2 = Point(0.0, 0.0)73print(p1.distance_to(p2)) # 5.074# p1.x = 10.0 # AttributeError: can't set attribute757677# ============================================================78# First-class functions and higher-order functions79# ============================================================80from typing import Callable818283def apply_discount(84 prices: Sequence[float],85 strategy: Callable[[float], float],86) -> list[float]:87 """Apply a discount strategy to all prices."""88 return [strategy(p) for p in prices]899091def flat_discount(amount: float) -> Callable[[float], float]:92 """Create a flat discount function."""93 def discount(price: float) -> float:94 return max(0.0, price - amount)95 return discount969798def percentage_discount(pct: float) -> Callable[[float], float]:99 """Create a percentage discount function."""100 def discount(price: float) -> float:101 return price * (1 - pct / 100)102 return discount103104105prices = [100.0, 250.0, 50.0, 175.0]106print(apply_discount(prices, flat_discount(20)))107# [80.0, 230.0, 30.0, 155.0]108print(apply_discount(prices, percentage_discount(15)))109# [85.0, 212.5, 42.5, 148.75]110111112# ============================================================113# Currying and partial application114# ============================================================115import functools116117118# Manual currying via closures119def curry_multiply(a: float):120 def inner(b: float):121 return a * b122 return inner123124125double = curry_multiply(2)126triple = curry_multiply(3)127print(double(5)) # 10128print(triple(5)) # 15129130131# functools.partial — the Pythonic way132def power(base: int, exponent: int) -> int:133 return base ** exponent134135136square = functools.partial(power, exponent=2)137cube = functools.partial(power, exponent=3)138print(square(5)) # 25139print(cube(5)) # 125140141# Partial with keyword arguments for configuration142import logging143144debug_log = functools.partial(logging.log, logging.DEBUG)145error_log = functools.partial(logging.log, logging.ERROR)146147148# ============================================================149# Function composition150# ============================================================151def compose(*funcs: Callable) -> Callable:152 """Compose functions right-to-left: compose(f, g, h)(x) = f(g(h(x)))."""153 def composed(x):154 result = x155 for fn in reversed(funcs):156 result = fn(result)157 return result158 return composed159160161def pipe(*funcs: Callable) -> Callable:162 """Compose functions left-to-right: pipe(f, g, h)(x) = h(g(f(x)))."""163 def piped(x):164 result = x165 for fn in funcs:166 result = fn(result)167 return result168 return piped169170171# Build a text processing pipeline172normalize = pipe(173 str.strip,174 str.lower,175 lambda s: s.replace(" ", " "),176)177178print(normalize(" Hello World ")) # "hello world"179180181# Compose validators182def validate_non_empty(s: str) -> str:183 if not s:184 raise ValueError("String must not be empty")185 return s186187188def validate_max_length(max_len: int) -> Callable[[str], str]:189 def validator(s: str) -> str:190 if len(s) > max_len:191 raise ValueError(f"String exceeds {max_len} chars")192 return s193 return validator194195196def validate_alphanumeric(s: str) -> str:197 if not s.replace(" ", "").isalnum():198 raise ValueError("String must be alphanumeric")199 return s200201202validate_username = pipe(203 validate_non_empty,204 validate_max_length(32),205 validate_alphanumeric,206 str.lower,207)208209print(validate_username("JohnDoe42")) # "johndoe42"210211212# ============================================================213# Practical: immutable configuration with chaining214# ============================================================215@dataclass(frozen=True)216class QueryBuilder:217 table: str218 conditions: tuple = ()219 order_by: str | None = None220 limit_val: int | None = None221222 def where(self, condition: str) -> "QueryBuilder":223 return replace(self, conditions=self.conditions + (condition,))224225 def order(self, column: str) -> "QueryBuilder":226 return replace(self, order_by=column)227228 def limit(self, n: int) -> "QueryBuilder":229 return replace(self, limit_val=n)230231 def build(self) -> str:232 sql = f"SELECT * FROM {self.table}"233 if self.conditions:234 sql += " WHERE " + " AND ".join(self.conditions)235 if self.order_by:236 sql += f" ORDER BY {self.order_by}"237 if self.limit_val is not None:238 sql += f" LIMIT {self.limit_val}"239 return sql240241242query = (243 QueryBuilder("users")244 .where("active = 1")245 .where("age > 18")246 .order("created_at DESC")247 .limit(50)248)249print(query.build())250# SELECT * FROM users WHERE active = 1 AND age > 18 ORDER BY created_at DESC LIMIT 50
🏋️ Practice Exercise
Exercises:
Write a pure function
transform_records(records: tuple[dict, ...], transformations: tuple[Callable, ...]) -> tuple[dict, ...]that applies a pipeline of transformation functions to each record without mutating any input. Each transformation takes a dict and returns a new dict.Implement a
curry(func)decorator that automatically curries any function of arbitrary arity.curry(lambda a, b, c: a + b + c)(1)(2)(3)should return6, andcurry(lambda a, b, c: a + b + c)(1, 2)(3)should also return6.Build a
@dataclass(frozen=True)based immutableConfigclass with nested frozen dataclasses. Implement aConfig.merge(other)method that deep-merges two configs, returning a new Config. Test that the original objects remain unchanged.Create a
compose_async(*funcs)function that composes async functions in the same waycomposeworks for sync functions. Test it with three async transformations on a string.Implement a functional
Eithertype (like Rust'sResult) withSuccessandFailurevariants. Add.map(fn),.flat_map(fn), and.get_or_else(default)methods. Use it to build a validation pipeline that collects errors instead of raising exceptions.Rewrite a class-based strategy pattern (e.g., shipping cost calculator with different strategies) using pure functions and
functools.partial. Compare the two approaches for testability and conciseness.
⚠️ Common Mistakes
Using mutable default arguments in 'pure' functions.
def process(items, cache={})shares the same dict across all calls. UseNoneas default and create inside the function:cache = cache if cache is not None else {}.Confusing currying with partial application. Currying transforms
f(a, b, c)intof(a)(b)(c)— each call takes exactly one argument. Partial application fixes some arguments and returns a function taking the rest:partial(f, a)returnsg(b, c). Python'sfunctools.partialdoes partial application, not currying.Over-using
lambdafor complex logic. Lambdas are limited to a single expression and cannot contain statements. Use named functions for anything non-trivial — they are easier to debug (stack traces show the name), test, and document.Assuming Python enforces immutability deeply.
frozen=Trueon a dataclass prevents reassignment of attributes, but if an attribute is a mutable object (like a list), the list itself can still be mutated. Use tuples and frozensets for truly immutable nested data.Creating deeply nested closures that are hard to debug. While closures are powerful, more than two levels of nesting makes code difficult to follow. Extract inner functions into named, testable units.
💼 Interview Questions
🎤 Mock Interview
Practice a live interview for Functional Programming Concepts