Functional Programming Techniques

Reviewed & published by Brayan K

Master functional programming patterns, pure functions, composition, and advanced FP architectures

Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What Functional Programming Actually Means

Functional programming is based on core principles:

PrincipleWhat It MeansReal-World Analogy
Pure FunctionsSame input → same output, no side effectsA calculator: 2+2 always equals 4
ImmutabilityData never changes; create new copiesEditing a photo creates a new file
First-Class FunctionsFunctions can be passed around like dataGiving someone a recipe card to follow
Higher-Order FunctionsFunctions that work with other functionsA chef who teaches other chefs techniques
Declarative StyleDescribe what, not how"Make me a sandwich" vs step-by-step instructions

Pure Functions

A pure function has no side effects, doesn't modify external state, and always produces the same output for the same input.

# Pure function - predictable, no side effects
def pure_add(x, y):
    return x + y

print(pure_add(2, 3))  # Always returns 5
print(pure_add(2, 3))  # Still returns 5

# Impure function - has side effects
count = 0

def impure_increment():
    global count
    count += 1
    return count

print(impure_increment())  # 1
print(impure_increment())  # 2 (different result!)

# Pure functions are:
# ✓ easier to test
# ✓ easier to debug
# ✓ safer to parallelize
# ✓ more reliable

# ✅ Expected output:
# 5
# 5
# 1
# 2

Immutability in Python

FP encourages transforming data instead of mutating it. Use immutable types and return new values.

# Immutable types: int, float, str, tuple, frozenset
# Mutable types: list, dict, set

# FP style - transform, don't mutate
nums = [1, 2, 3, 4, 5]

# Good FP style - create new list
doubled = [x * 2 for x in nums]
print(f"Original: {nums}")
print(f"Doubled: {doubled}")

# Avoid mutation (when following FP)
# nums.append(6)  # modifies original

# Using tuples for immutability
point = (3, 4)
# point[0] = 5  # Error! Tuples are immutable

# Frozen sets
fs = frozenset([1, 2, 3])
# fs.add(4)  # Error! Frozen sets are immutable

print("\nFP encourages: every transformation returns new value")

# ✅ Expected output:
# Original: [1, 2, 3, 4, 5]
# Doubled: [2, 4, 6, 8, 10]
#
# FP encourages: every transformation returns new value

Higher-Order Functions

Functions that take functions as arguments or return functions. Core to functional programming.

# Higher-order function - takes function as argument
def apply_twice(func, value):
    return func(func(value))

def add_one(x):
    return x + 1

def double(x):
    return x * 2

result1 = apply_twice(add_one, 5)
print(f"Apply add_one twice to 5: {result1}")  # 7

result2 = apply_twice(double, 3)
print(f"Apply double twice to 3: {result2}")   # 12

# Higher-order function - returns a function
def make_multiplier(n):
    def multiplier(x):
        return x * n
    return multiplier

times_3 = make_multiplier(3)
times_5 = make_multiplier(5)

print(f"\n3 × 4 = {times_3(4)}")
print(f"5 × 4 = {times_5(4)}")

# Used in: callbacks, decorators, event systems, ML pipelines

# ✅ Expected output:
# Apply add_one twice to 5: 7
# Apply double twice to 3: 12
#
# 3 × 4 = 12
# 5 × 4 = 20

map(), filter(), reduce() — Core FP Tools

The fundamental functional transformations for working with sequences.

from functools import reduce

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

# map() - transform each element
doubled = list(map(lambda x: x * 2, nums))
print(f"Doubled: {doubled}")

# filter() - keep only matching items
evens = list(filter(lambda x: x % 2 == 0, nums))
print(f"Evens: {evens}")

# reduce() - combine into single value
total = reduce(lambda a, b: a + b, nums)
print(f"Sum: {total}")

product = reduce(lambda a, b: a * b, nums)
print(f"Product: {product}")

# Chaining operations
result = list(map(
    lambda x: x ** 2,
    filter(lambda x: x % 2 == 0, nums)
))
print(f"\nSquares of evens: {result}")

# List comprehension equivalent (often more Pythonic)
result2 = [x ** 2 for x in nums if x % 2 == 0]
print(f"Same with comprehension: {result2}")

# ✅ Expected output:
# Doubled: [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
# Evens: [2, 4, 6, 8, 10]
# Sum: 55
# Product: 3628800
#
# Squares of evens: [4, 16, 36, 64, 100]
# Same with comprehension: [4, 16, 36, 64, 100]

Worked Example: One Basket, One Pipeline

Here are the three tools doing a job you would actually be asked to do: turn a shopping basket into line totals, pick out the big lines, and add everything up — without changing the original data. Every comment says what the line produces.

from functools import reduce

# Each row is (item, unit price, quantity).
basket = [("apple", 0.50, 4), ("bread", 2.20, 1), ("cheese", 4.75, 2), ("milk", 1.15, 3)]

# map() applies ONE function to every element and hands back a lazy iterator.
# "Lazy" means no work has happened yet - it is a promise, not a list.
line_totals = map(lambda row: (row[0], round(row[1] * row[2], 2)), basket)

# list() forces the promise to be kept, and now you have real data.
line_totals = list(line_totals)
print("line totals :", line_totals)

# filter() keeps only the elements where the function returns True.
expensive = list(filter(lambda pair: pair[1] >= 3, line_totals))
print("3 or more   :", expensive)

# reduce() folds a whole list into ONE value, carrying a running result.
# The 0 at the end is the starting value - and the answer for an empty list.
total = reduce(lambda running, pair: running + pair[1], line_totals, 0)
print("basket total:", round(total, 2))

# The gotcha that catches everyone: an iterator is used up after ONE pass.
once = map(str.upper, ["a", "b"])
print("first pass  :", list(once))    # ['A', 'B']
print("second pass :", list(once))    # []  - exhausted, not broken

# The same transformation as a comprehension. In Python this is usually the
# more readable choice; reach for map/filter when you already HAVE a function.
print("comprehension:", [(name, round(price * qty, 2)) for name, price, qty in basket])

# ✅ Expected output:
# line totals : [('apple', 2.0), ('bread', 2.2), ('cheese', 9.5), ('milk', 3.45)]
# 3 or more   : [('cheese', 9.5), ('milk', 3.45)]
# basket total: 17.15
# first pass  : ['A', 'B']
# second pass : []
# comprehension: [('apple', 2.0), ('bread', 2.2), ('cheese', 9.5), ('milk', 3.45)]

Lambda Functions

Anonymous functions perfect for functional pipelines and quick transformations.

# Basic lambda syntax
square = lambda x: x * x
print(f"Square of 5: {square(5)}")

# Lambdas with multiple arguments
add = lambda x, y: x + y
print(f"3 + 4 = {add(3, 4)}")

# Lambdas in sorting
users = [
    {"name": "Alice", "age": 30},
    {"name": "Bob", "age": 25},
    {"name": "Charlie", "age": 35}
]

sorted_by_age = sorted(users, key=lambda u: u["age"])
print("\nSorted by age:")
for user in sorted_by_age:
    print(f"  {user['name']}: {user['age']}")

# Lambdas in functional chains
nums = [1, 2, 3, 4, 5]
result = list(
    map(lambda x: x * 2,
        filter(lambda x: x > 2, nums)
    )
)
print(f"\nProcessed: {result}")

# Used for: transformations, sorting keys, callbacks, inline rules

# ✅ Expected output:
# Square of 5: 25
# 3 + 4 = 7
#
# Sorted by age:
#   Bob: 25
#   Alice: 30
#   Charlie: 35
#
# Processed: [6, 8, 10]

🎯 Your Turn: Write the Four Lambdas

The plumbing is already in place. All that is missing is the small function inside each call — one for map, one for filter, one for reduce, and one to act as a sort key. Fill in each ___ and match the expected output.

from functools import reduce

# 🎯 YOUR TURN — replace each ___ with the body of the lambda.

nums = [3, 8, 1, 12, 7, 4]

tripled = list(map(lambda n: ___, nums))          # 👉 triple every number
print("tripled:", tripled)

big = list(filter(lambda n: ___, nums))           # 👉 keep only numbers greater than 5
print("big    :", big)

# reduce runs left to right: 'a' is the result so far, 'b' is the next number.
largest = reduce(lambda a, b: ___, nums)          # 👉 give back whichever is bigger
print("largest:", largest)

words = ["banana", "fig", "cherry", "kiwi"]
print("by length:", sorted(words, key=lambda w: ___))   # 👉 sort by word LENGTH

# ✅ Expected output:
# tripled: [9, 24, 3, 36, 21, 12]
# big    : [8, 12, 7]
# largest: 12
# by length: ['fig', 'kiwi', 'banana', 'cherry']

Function Composition

Building complex operations by chaining simple pure functions.

# Composition utility
def compose(*funcs):
    def wrapper(x):
        for f in reversed(funcs):
            x = f(x)
        return x
    return wrapper

# Simple functions
double = lambda x: x * 2
increment = lambda x: x + 1
square = lambda x: x * x

# Compose them
f1 = compose(increment, double)
print(f"Double then increment 5: {f1(5)}")  # 11

f2 = compose(square, increment, double)
print(f"Double, increment, square 3: {f2(3)}")  # 49

# String processing pipeline
clean = compose(
    str.strip,
    str.lower,
    lambda s: s.replace("!", "")
)

text = "  HELLO WORLD!  "
print(f"\nOriginal: '{text}'")
print(f"Cleaned: '{clean(text)}'")

# Used in: ML pipelines, data validation, text processing

# ✅ Expected output:
# Double then increment 5: 11
# Double, increment, square 3: 49
#
# Original: '  HELLO WORLD!  '
# Cleaned: 'hello world'

functools.partial — Pre-Configuring Functions

Create specialized versions of functions by "freezing" some arguments.

from functools import partial
import operator

# Basic partial application
def power(base, exp):
    return base ** exp

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

print(f"5² = {square(5)}")
print(f"5³ = {cube(5)}")

# With operators
times_10 = partial(operator.mul, 10)
result = list(map(times_10, [1, 2, 3, 4, 5]))
print(f"\nMultiply by 10: {result}")

# Practical example: logging with context
def log_message(level, message, context=""):
    print(f"[{level}] {message} {context}")

log_error = partial(log_message, "ERROR")
log_info = partial(log_message, "INFO")

log_error("Database connection failed")
log_info("User logged in", context="user_id=123")

# Used in: ML preprocessing, API wrappers, event systems

# ✅ Expected output:
# 5² = 25
# 5³ = 125
#
# Multiply by 10: [10, 20, 30, 40, 50]
# [ERROR] Database connection failed 
# [INFO] User logged in user_id=123

Generator-Based Functional Programming

Generators provide lazy evaluation and memory-efficient functional pipelines.

# Generator pipeline for processing data
def numbers(n):
    """Generate numbers"""
    for i in range(n):
        yield i

def only_evens(nums):
    """Filter to only evens"""
    for n in nums:
        if n % 2 == 0:
            yield n

def squared(nums):
    """Square each number"""
    for n in nums:
        yield n ** 2

# Chain generators (lazy evaluation)
pipeline = squared(only_evens(numbers(10)))

print("Processing numbers 0-9:")
for result in pipeline:
    print(f"  {result}")

# File processing pipeline
def read_lines(text):
    """Simulate file reading"""
    lines = text.split("\n")
    for line in lines:
        yield line.strip()

def filter_errors(lines):
    """Keep only error lines"""
    for line in lines:
        if "ERROR" in line:
            yield line

# Example log data
log_data = """INFO: System started
ERROR: Connection timeout
INFO: User logged in
ERROR: File not found
INFO: Request processed"""

print("\nError lines:")
for line in filter_errors(read_lines(log_data)):
    print(f"  {line}")

# ✅ Expected output:
# Processing numbers 0-9:
#   0
#   4
#   16
#   36
#   64
#
# Error lines:
#   ERROR: Connection timeout
#   ERROR: File not found

Advanced: Currying

Transform multi-argument functions into chains of single-argument functions.

# Manual currying
def curry_add(a):
    def add_b(b):
        def add_c(c):
            return a + b + c
        return add_c
    return add_b

result = curry_add(1)(2)(3)
print(f"Curried add: {result}")

# Generic curry decorator
def curry(func):
    """Transform f(a, b, c) into f(a)(b)(c)"""
    def curried(a):
        return lambda b: lambda c: func(a, b, c)
    return curried

@curry
def multiply(a, b, c):
    return a * b * c

result = multiply(2)(3)(4)
print(f"Curried multiply: {result}")

# Partial application of curried function
times_2 = multiply(2)
times_2_3 = times_2(3)
print(f"2 × 3 × 5 = {times_2_3(5)}")

# Practical use: configuration
def make_greeting(greeting):
    def with_name(name):
        def with_punctuation(punct):
            return f"{greeting}, {name}{punct}"
        return with_punctuation
    return with_name

hello = make_greeting("Hello")
hello_alice = hello("Alice")
print(f"\n{hello_alice('!')}")
print(f"{hello_alice('.')}")

# ✅ Expected output:
# Curried add: 6
# Curried multiply: 24
# 2 × 3 × 5 = 30
#
# Hello, Alice!
# Hello, Alice.

Decorators — Functional Power Tools

Decorators are higher-order functions that transform and enhance functions.

from functools import wraps
import time

# Simple logging decorator
def log_calls(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}({args}, {kwargs})")
        result = func(*args, **kwargs)
        print(f"  → {result}")
        return result
    return wrapper

@log_calls
def add(a, b):
    return a + b

print("Example 1: Logging")
add(3, 5)
add(10, 20)

# Timing decorator
def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timer
def slow_function():
    time.sleep(0.1)
    return "Done"

print("\nExample 2: Timing")
slow_function()

# Decorators stack
@log_calls
@timer
def compute(x):
    time.sleep(0.05)
    return x ** 2

print("\nExample 3: Stacked decorators")
compute(5)

Functional Error Handling

Handle errors with values instead of exceptions, enabling safer composition.

# Option/Maybe pattern
class Maybe:
    def __init__(self, value):
        self.value = value
    
    def bind(self, func):
        if self.value is None:
            return Maybe(None)
        try:
            return Maybe(func(self.value))
        except:
            return Maybe(None)
    
    def get_or(self, default):
        return self.value if self.value is not None else default

# Safe operations
def safe_divide(x):
    return 100 / x if x != 0 else None

def double(x):
    return x * 2

# Chain safely
result1 = Maybe(10).bind(safe_divide).bind(double)
print(f"Maybe(10) → divide → double: {result1.get_or('Error')}")

result2 = Maybe(0).bind(safe_divide).bind(double)
print(f"Maybe(0) → divide → double: {result2.get_or('Error')}")

# Result type (Success/Failure)
class Result:
    def __init__(self, ok=None, err=None):
        self.ok = ok
        self.err = err
    
    def bind(self, func):
        if self.err:
            return self
        try:
            return Result(ok=func(self.ok))
        except Exception as e:
            return Result(err=str(e))

# Safe parsing pipeline
def parse_int(s):
    return int(s)

result = Result(ok="123").bind(parse_int).bind(lambda x: x * 2)
print(f"\nParse '123': {result.ok if result.ok else result.err}")

result = Result(ok="abc").bind(parse_int).bind(lambda x: x * 2)
print(f"Parse 'abc': {result.ok if result.ok else result.err}")

# ✅ Expected output:
# Maybe(10) → divide → double: 20.0
# Maybe(0) → divide → double: Error
#
# Parse '123': 246
# Parse 'abc': invalid literal for int() with base 10: 'abc'

Immutable Data Structures

Use frozen dataclasses and immutable collections for safer functional code.

from dataclasses import dataclass
from typing import FrozenSet

# Frozen dataclass (immutable)
@dataclass(frozen=True)
class Point:
    x: int
    y: int
    
    def move(self, dx, dy):
        """Returns new Point instead of modifying"""
        return Point(self.x + dx, self.y + dy)

p1 = Point(0, 0)
p2 = p1.move(3, 4)
print(f"Original: {p1}")
print(f"Moved: {p2}")

# p1.x = 10  # Error! Frozen dataclass is immutable

# Frozen dataclass with methods
@dataclass(frozen=True)
class User:
    id: int
    name: str
    email: str
    
    def with_email(self, new_email):
        """Create new User with updated email"""
        return User(self.id, self.name, new_email)

user = User(1, "Alice", "[email protected]")
updated = user.with_email("[email protected]")

print(f"\nOriginal user: {user.email}")
print(f"Updated user: {updated.email}")

# Frozen sets (immutable sets)
tags: FrozenSet[str] = frozenset(["python", "fp", "coding"])
# tags.add("new")  # Error! Can't modify frozen set

print(f"\nTags: {tags}")
print("Frozen sets are hashable and thread-safe!")

Real-World Functional Pipeline

A complete functional architecture for data processing.

from functools import reduce

# Pure transformation functions
def parse_line(line):
    """Parse CSV-like line"""
    parts = line.strip().split(",")
    return {"name": parts[0], "value": int(parts[1])}

def is_valid(record):
    """Check if record is valid"""
    return record["value"] > 0

def extract_value(record):
    """Extract just the value"""
    return record["value"]

def sum_values(acc, value):
    """Accumulator for reduce"""
    return acc + value

# Sample data
data = """Alice,100
Bob,-50
Charlie,200
David,0
Eve,150"""

# Functional pipeline
lines = data.split("\n")

# Method 1: Chained operations
result = reduce(
    sum_values,
    map(
        extract_value,
        filter(
            is_valid,
            map(parse_line, lines)
        )
    ),
    0
)

print(f"Sum of valid values: {result}")

# Method 2: Generator pipeline (more readable)
def pipeline(lines):
    parsed = (parse_line(line) for line in lines)
    valid = (record for record in parsed if is_valid(record))
    values = (extract_value(record) for record in valid)
    return reduce(sum_values, values, 0)

result2 = pipeline(data.split("\n"))
print(f"Pipeline result: {result2}")

# Benefits:
# ✓ No side effects
# ✓ Fully testable
# ✓ Composable
# ✓ Memory efficient
# ✓ Easy to parallelize

# ✅ Expected output:
# Sum of valid values: 450
# Pipeline result: 450

🎯 Mini-Challenge: A Revenue Report, Functionally

Build a small sales report as a pipeline: filter, then transform, then sort, then fold to a single number. The one hard rule is that orders must be untouched when you are finished — no .append(), no .sort(), no assigning into a dictionary.

# 🎯 MINI-CHALLENGE: a revenue report, functionally
#
# Start with this data (copy it in as-is):
#
# orders = [
#     {"item": "keyboard", "price": 45.0,  "qty": 2, "paid": True},
#     {"item": "mouse",    "price": 18.5,  "qty": 1, "paid": False},
#     {"item": "monitor",  "price": 199.0, "qty": 1, "paid": True},
#     {"item": "cable",    "price": 6.25,  "qty": 4, "paid": True},
# ]
#
# 1. filter out anything where "paid" is False
# 2. map each remaining order to a tuple: (item, price * qty rounded to 2 dp)
# 3. sort those tuples by value, LARGEST first  (sorted(..., reverse=True))
# 4. print each tuple on its own line
# 5. reduce the values into one total and print  revenue: <total>
#
# Rule: orders must be exactly as it started when you are done. Every step
# makes a new value rather than editing the old one.
#
# ✅ Expected output:
# ('monitor', 199.0)
# ('keyboard', 90.0)
# ('cable', 25.0)
# revenue: 314.0

# your code here

Summary

You've learned comprehensive functional programming in Python:

Functional programming helps you build cleaner, more predictable, and easier-to-maintain code. These patterns are essential for data engineering, ML pipelines, ETL systems, and modern backend architectures.

📋 Quick Reference — Functional Programming

Tool / SyntaxWhat it does
map(fn, iterable)Apply fn to every element
filter(fn, iterable)Keep elements where fn returns True
functools.reduce(fn, iterable)Accumulate into a single value
lambda x: x * 2Anonymous inline function
functools.partial(fn, arg)Pre-fill some function arguments

🎉 Great work! You've completed this lesson.

You can now write purely functional Python using map, filter, reduce, and composition — essential for data pipelines and ML workflows.

Practice quiz

What defines a pure function?

  • It prints output
  • It uses global state
  • Same input always gives the same output with no side effects
  • It modifies its arguments

Answer: Same input always gives the same output with no side effects. A pure function always returns the same output for the same input and has no side effects, making it predictable and testable.

Which of these Python types is IMMUTABLE?

  • tuple
  • list
  • dict
  • set

Answer: tuple. Tuples (like int, float, str, frozenset) are immutable; lists, dicts, and sets are mutable.

What does map(lambda x: x * 2, [1, 2, 3]) produce when wrapped in list()?

  • [1, 2, 3]
  • [1, 4, 9]
  • 6
  • [2, 4, 6]

Answer: [2, 4, 6]. map applies the function to each element, doubling them to give [2, 4, 6].

What does filter(lambda x: x % 2 == 0, nums) keep?

  • Odd numbers
  • Even numbers (where the function returns True)
  • All numbers
  • Nothing

Answer: Even numbers (where the function returns True). filter keeps only elements for which the function returns True; here that means the even numbers.

What does reduce(lambda a, b: a + b, [1, 2, 3, 4, 5]) return?

  • 15
  • 120
  • [1,2,3,4,5]
  • 5

Answer: 15. reduce combines the list into a single value by repeated addition: 1+2+3+4+5 = 15.

What is a higher-order function?

  • A function with many lines
  • A recursive function
  • A function that takes or returns other functions
  • A function defined at module top level

Answer: A function that takes or returns other functions. A higher-order function takes functions as arguments and/or returns functions — core to functional programming.

What does a lambda expression create?

  • A class
  • An anonymous (unnamed) inline function
  • A generator
  • A decorator

Answer: An anonymous (unnamed) inline function. lambda creates a small anonymous function, handy for sorting keys, callbacks, and inline transformations.

What advantage do generators give in functional pipelines?

  • They sort data
  • They run in parallel automatically
  • They validate types
  • Lazy evaluation — memory-efficient, one item at a time

Answer: Lazy evaluation — memory-efficient, one item at a time. Generators yield items lazily, so a chained generator pipeline processes data one element at a time without building large lists in memory.

What does functools.partial do?

  • Splits a function in half
  • Creates a specialized function by pre-filling (freezing) some arguments
  • Runs a function partially
  • Caches results

Answer: Creates a specialized function by pre-filling (freezing) some arguments. partial pre-fills some arguments to produce a new, specialized callable, e.g. square = partial(power, exp=2).

What is currying?

  • Caching function results
  • Running functions concurrently
  • Transforming f(a, b, c) into a chain f(a)(b)(c) of single-argument calls
  • Adding type hints

Answer: Transforming f(a, b, c) into a chain f(a)(b)(c) of single-argument calls. Currying turns a multi-argument function into a sequence of single-argument functions, called like multiply(2)(3)(4).

Continue this course