Skip to main content

Partial Application and Currying - functools.partial, operator, and Function Pipelines

Reading time: ~28 minutes | Level: Intermediate → Engineering

Before reading further, predict the output of this program:

from functools import partial

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

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

print(square(4)) # ?
print(cube(3)) # ?
print(square.__name__) # ?
Show Answer
16
27
AttributeError: 'functools.partial' object has no attribute '__name__'

square(4) calls power(4, exponent=2)16. cube(3) calls power(3, exponent=3)27. But square.__name__ raises AttributeError - a functools.partial object does not have a __name__ attribute. It has .func (the original function), .args (pre-filled positional args), and .keywords (pre-filled keyword args). If you want the wrapped function's name, use square.func.__name__, which gives "power". This is the key distinction from a lambda or a functools.wraps-wrapped function.

Partial application is one of the most practically useful tools in functional programming. Currying is its theoretical cousin. Understanding both - and their difference - makes you a better designer of APIs, callbacks, and data pipelines. This lesson covers both at engineering depth.

What You Will Learn

  • functools.partial: how it works internally, what the returned object looks like, and how to inspect it
  • Pre-filling positional vs keyword arguments: the difference and when order matters
  • When to prefer partial over lambda: readability, introspectability, debuggability
  • Currying vs partial application: the precise distinction
  • Implementing currying in Python manually
  • The operator module as curried-style operations: itemgetter, attrgetter, methodcaller
  • Function composition: manual implementation and functools.reduce-based pipelines
  • Real-world patterns: sorted() with operator, Django ORM Q objects, data transformation pipelines

Prerequisites

  • Lesson 09 (functools Module) - partial was introduced there; this lesson covers it at full depth
  • Lesson 06 (Closures) - currying is implemented using closures
  • Lesson 02 (map/filter/reduce) - operator functions are used with these higher-order functions

Part 1 - functools.partial In Depth

What partial Returns

functools.partial does not call the function. It returns a new callable - a functools.partial object - that, when called, calls the original function with the pre-filled arguments merged with the new arguments.

from functools import partial
import inspect

def send_email(recipient: str, subject: str, body: str, cc: str = "") -> dict:
return {
"to": recipient,
"subject": subject,
"body": body,
"cc": cc,
}

# Pre-fill 'recipient' as a positional argument
notify_admin = partial(send_email, "[email protected]")

# Pre-fill 'subject' and 'cc' as keyword arguments
alert = partial(send_email, subject="ALERT", cc="[email protected]")

print(notify_admin("Server Down", "All services offline"))
# {'to': '[email protected]', 'subject': 'Server Down', 'body': 'All services offline', 'cc': ''}

print(alert("[email protected]", body="Deploy failed"))
# {'to': '[email protected]', 'subject': 'ALERT', 'body': 'Deploy failed', 'cc': '[email protected]'}

Inspecting Partial Objects

from functools import partial

def multiply(a: float, b: float, c: float = 1.0) -> float:
return a * b * c

double = partial(multiply, b=2.0)

print(double.func) # <function multiply at 0x...>
print(double.args) # () - no pre-filled positional args
print(double.keywords) # {'b': 2.0}

# Get the original function's name through .func:
print(double.func.__name__) # 'multiply'

# partial objects do NOT have __name__ directly:
try:
print(double.__name__)
except AttributeError as e:
print(f"AttributeError: {e}")
# AttributeError: 'functools.partial' object has no attribute '__name__'

# But partial objects DO have __doc__ (copied from the wrapped function):
print(double.__doc__ is None or "Return" in str(double.__doc__) or True)
# True - partial does have __doc__ (None if the original had no docstring)

How Arguments Are Merged

When a partial object is called, arguments are merged in this order:

  1. Pre-filled positional args (partial.args) come first
  2. New positional args come after them
  3. Pre-filled keyword args (partial.keywords) are merged with new keyword args
  4. New keyword args override pre-filled keyword args if there is a conflict
from functools import partial

def f(a, b, c, d="D"):
return (a, b, c, d)

# Pre-fill a=1 positionally
g = partial(f, 1)
print(g(2, 3)) # (1, 2, 3, 'D')
print(g(2, 3, d="X")) # (1, 2, 3, 'X') - keyword override works

# Pre-fill c and d as keywords
h = partial(f, c=30, d="Z")
print(h(10, 20)) # (10, 20, 30, 'Z')
print(h(10, 20, d="Y")) # (10, 20, 30, 'Y') - new d overrides pre-filled d
danger

Do not confuse partial(f, x) (positional pre-fill) with partial(f, arg=x) (keyword pre-fill) when the function uses positional-only parameters or has specific positional ordering. Pre-filling positionally prepends to the argument list; pre-filling by keyword bypasses positional ordering. If you mix both for the same parameter, you get a TypeError: got multiple values for argument.

from functools import partial

def greet(name: str, greeting: str) -> str:
return f"{greeting}, {name}!"

# Pre-fill name positionally
say_hello = partial(greet, "Alice")
print(say_hello("Hello")) # Hello, Alice! ✓

# Conflict: name pre-filled positionally, also passed positionally
try:
say_hello("Bob", "Hi") # TypeError: greet() takes 2 positional arguments but 3 were given
except TypeError as e:
print(e)

Part 2 - partial vs lambda

Both partial and lambda can create specialised callables from generic functions. The choice between them is a matter of readability, introspectability, and debuggability.

from functools import partial
import operator

data = [
{"name": "Alice", "score": 95, "grade": "A"},
{"name": "Bob", "score": 72, "grade": "B"},
{"name": "Carol", "score": 88, "grade": "A"},
]

# Sorting with lambda - functional but opaque at a glance
data_lambda = sorted(data, key=lambda x: x["score"])

# Sorting with operator.itemgetter - clearer intent
data_itemgetter = sorted(data, key=operator.itemgetter("score"))

# Sorting with partial - useful when the key function needs configuration
def score_with_bonus(item: dict, bonus: int) -> int:
return item["score"] + bonus

# lambda for bonus: works but less readable
data_bonus_lambda = sorted(data, key=lambda x: score_with_bonus(x, bonus=5))

# partial for bonus: intention is explicit, inspectable
add_5_bonus = partial(score_with_bonus, bonus=5)
data_bonus_partial = sorted(data, key=add_5_bonus)

print(add_5_bonus.func.__name__) # 'score_with_bonus'
print(add_5_bonus.keywords) # {'bonus': 5}
# A lambda gives you none of this introspection
tip

Prefer partial over lambda for pre-filling arguments: it is more readable (the original function name is visible through .func.__name__), introspectable (inspect .args and .keywords to see what was pre-filled), and easier to debug in stack traces (lambda shows as <lambda> in tracebacks; a function passed to partial shows its real name).

Part 3 - Currying vs Partial Application

These two terms are frequently confused. They are related but distinct.

Partial Application

Partial application fixes some arguments of a function, returning a new function that takes the remaining arguments. This is exactly what functools.partial does.

from functools import partial

def add(a: int, b: int, c: int) -> int:
return a + b + c

# Partial application: fix a=1, return a function of (b, c)
add_one = partial(add, 1)
print(add_one(2, 3)) # 6 - still takes two arguments at once

Currying

Currying transforms a function of N arguments into a chain of N single-argument functions. Each call takes exactly one argument and returns either the final result or another single-argument function.

# add(a, b, c) → curried form: add(a)(b)(c)
def curried_add(a: int):
def inner_b(b: int):
def inner_c(c: int):
return a + b + c
return inner_c
return inner_b

result = curried_add(1)(2)(3)
print(result) # 6

# Intermediate functions are useful:
add_one = curried_add(1) # single-arg function
add_one_two = add_one(2) # another single-arg function
print(add_one_two(3)) # 6
print(add_one_two(10)) # 13

The Key Distinction

PropertyPartial ApplicationCurrying
FillsAny subset of arguments at onceExactly one argument per call
ReturnsNew callable taking remaining argsSingle-arg function (or final result)
Python supportfunctools.partial nativelyManual implementation or third-party
OriginPractical toolMathematical concept (lambda calculus)
from functools import partial

def add(a: int, b: int, c: int) -> int:
return a + b + c

# Partial application - fill 2 at once:
add_one_two = partial(add, 1, 2) # fills a=1, b=2 simultaneously
print(add_one_two(3)) # 6

# Currying - fill one at a time:
add_curried = curried_add(1)(2) # two separate calls, one arg each
print(add_curried(3)) # 6

Python does not curry functions by default - every function takes all its arguments at once (or uses *args/**kwargs). Haskell and Scala curry all functions automatically.

Part 4 - Implementing Currying in Python

Manual Currying with Closures

def curry2(func):
"""Curry a 2-argument function."""
def curried(a):
def inner(b):
return func(a, b)
return inner
return curried

@curry2
def add(a: int, b: int) -> int:
return a + b

add5 = add(5)
print(add5(3)) # 8
print(add5(10)) # 15

General Curry Using inspect

import inspect
from functools import wraps

def curry(func):
"""
Curry a function of any arity.
Supports currying for functions with fixed positional parameters.
"""
n_args = len(inspect.signature(func).parameters)

@wraps(func)
def curried(*args):
if len(args) >= n_args:
return func(*args[:n_args])
def accumulate(*more_args):
return curried(*(args + more_args))
return accumulate

return curried

@curry
def add3(a: int, b: int, c: int) -> int:
return a + b + c

print(add3(1)(2)(3)) # 6 - fully curried
print(add3(1, 2)(3)) # 6 - partial application then final arg
print(add3(1)(2, 3)) # 6 - one arg then two at once
print(add3(1, 2, 3)) # 6 - all three at once

# Intermediate partially-applied functions:
add1 = add3(1)
add1_2 = add1(2)
print(add1_2(3)) # 6
print(add1_2(10)) # 13
note

Currying is the norm in functional languages like Haskell (where all functions are automatically curried) and Scala. Python's approach is partial application via functools.partial - you fill some arguments and get a new callable for the rest, but you are not required to fill one at a time. Use the curry decorator above when you want Haskell-style one-at-a-time calling; use functools.partial for practical everyday argument pre-filling.

Part 5 - The operator Module as Curried-Style Operations

The operator module provides function equivalents for Python's built-in operators and common access patterns. These functions are ideal for use with map, filter, sorted, and functools.reduce - much cleaner than lambdas.

operator.itemgetter

Returns a callable that retrieves the specified item(s) from its operand.

import operator

# Single key
get_name = operator.itemgetter("name")
get_score = operator.itemgetter("score")

students = [
{"name": "Alice", "score": 95, "year": 3},
{"name": "Bob", "score": 72, "year": 1},
{"name": "Carol", "score": 88, "year": 2},
]

print(sorted(students, key=get_score))
# [Bob(72), Carol(88), Alice(95)]

# Multiple keys - returns a tuple for multi-level sorting
get_year_score = operator.itemgetter("year", "score")
print(sorted(students, key=get_year_score))
# Sorted by year first, then score within same year

# Index-based access (works on sequences too)
get_first = operator.itemgetter(0)
get_second = operator.itemgetter(1)
pairs = [(3, "c"), (1, "a"), (2, "b")]
print(sorted(pairs, key=get_first)) # by first element
print(list(map(get_second, pairs))) # extract second elements: ['c', 'a', 'b']

operator.attrgetter

Returns a callable that retrieves the specified attribute(s) from its operand. Supports dotted names for nested access.

import operator
from dataclasses import dataclass

@dataclass
class Address:
city: str
country: str

@dataclass
class Person:
name: str
age: int
address: Address

people = [
Person("Alice", 30, Address("London", "GB")),
Person("Bob", 25, Address("Paris", "FR")),
Person("Carol", 35, Address("London", "GB")),
]

# Sort by age
by_age = operator.attrgetter("age")
print(sorted(people, key=by_age))
# [Bob(25), Alice(30), Carol(35)]

# Sort by nested attribute: address.city
by_city = operator.attrgetter("address.city")
print(sorted(people, key=by_city))
# London people first, then Paris

# Multiple attributes: sort by country, then age
by_country_age = operator.attrgetter("address.country", "age")
print(sorted(people, key=by_country_age))
# FR/Bob(25), GB/Alice(30), GB/Carol(35)

# Extract a single attribute from a collection
names = list(map(operator.attrgetter("name"), people))
print(names) # ['Alice', 'Bob', 'Carol']

operator.methodcaller

Returns a callable that calls the named method on its operand, with optional arguments.

import operator

# Call .lower() on strings
lower = operator.methodcaller("lower")
words = ["Hello", "WORLD", "Python"]
print(list(map(lower, words))) # ['hello', 'world', 'python']

# Call .strip() with arguments
strip_stars = operator.methodcaller("strip", "*")
texts = ["**hello**", "*world*", "python"]
print(list(map(strip_stars, texts))) # ['hello', 'world', 'python']

# Call .replace() with arguments
normalise = operator.methodcaller("replace", "-", "_")
slugs = ["my-app", "hello-world", "fast-api"]
print(list(map(normalise, slugs))) # ['my_app', 'hello_world', 'fast_api']

# Useful in sorted() for method-based keys
class Version:
def __init__(self, s: str):
self.version = tuple(int(x) for x in s.split("."))
self._raw = s
def as_tuple(self) -> tuple:
return self.version
def __repr__(self):
return self._raw

versions = [Version("2.1.0"), Version("1.0.5"), Version("1.2.0")]
by_version_tuple = operator.methodcaller("as_tuple")
print(sorted(versions, key=by_version_tuple))
# [1.0.5, 1.2.0, 2.1.0]
warning

partial with keyword arguments only works if the function signature accepts those keywords. If you do partial(f, unknown_kwarg=x) where f does not have unknown_kwarg in its signature, Python does not raise an error at partial() call time - it raises TypeError only when the partial object is actually called. This makes bugs with mistyped keyword argument names invisible until runtime.

Part 6 - Function Composition

Composing functions means chaining them so the output of one becomes the input of the next. This is the functional alternative to nested function calls or explicit intermediate variables.

Manual Composition

from typing import Callable, TypeVar

T = TypeVar("T")

def compose2(f: Callable, g: Callable) -> Callable:
"""Compose two functions right-to-left: compose2(f, g)(x) = f(g(x))"""
def composed(x):
return f(g(x))
return composed

def pipe2(f: Callable, g: Callable) -> Callable:
"""Compose two functions left-to-right: pipe2(f, g)(x) = g(f(x))"""
def piped(x):
return g(f(x))
return piped

double = lambda x: x * 2
add_ten = lambda x: x + 10

double_then_add = pipe2(double, add_ten)
add_then_double = compose2(double, add_ten)

print(double_then_add(5)) # (5*2)+10 = 20
print(add_then_double(5)) # (5+10)*2 = 30

functools.reduce for Multi-Function Pipelines

from functools import reduce
from typing import Callable

def pipe(*funcs: Callable) -> Callable:
"""
Left-to-right composition of any number of functions.
pipe(f, g, h)(x) == h(g(f(x)))
"""
return reduce(lambda f, g: lambda x: g(f(x)), funcs)

def compose(*funcs: Callable) -> Callable:
"""
Right-to-left composition of any number of functions.
compose(f, g, h)(x) == f(g(h(x)))
"""
return reduce(lambda f, g: lambda x: f(g(x)), funcs)

# Build a text cleaning pipeline
clean_text = pipe(
str.strip,
str.lower,
lambda s: s.replace(" ", " "), # collapse double spaces
lambda s: s.replace(" ", "_"), # spaces to underscores
)

print(clean_text(" Hello World ")) # hello_world
print(clean_text(" Python Rocks ")) # python_rocks

# Build a numeric transformation pipeline
transform_number = pipe(
lambda x: x * 2,
lambda x: x + 1,
lambda x: x ** 2,
)

print(transform_number(3)) # ((3*2)+1)^2 = 49

Practical Pipeline with operator and partial

from functools import partial, reduce
import operator

def pipeline(*funcs):
"""Create a data transformation pipeline."""
return reduce(lambda f, g: lambda x: g(f(x)), funcs)

# A data cleaning pipeline for a list of records
normalize_name = operator.methodcaller("strip")
to_lower = operator.methodcaller("lower")
replace_spaces = partial(str.replace, " ", "_") # Note: str.replace needs 'self' as first arg

# Better: use lambda or a helper for the str.replace case
def slugify(s: str) -> str:
return s.strip().lower().replace(" ", "_")

def truncate(max_len: int):
"""Returns a function that truncates strings to max_len."""
return lambda s: s[:max_len]

process_tag = pipeline(
slugify,
truncate(20),
)

tags = [" Python Programming ", "Data Science", "Machine Learning & AI"]
processed = list(map(process_tag, tags))
print(processed)
# ['python_programming', 'data_science', 'machine_learning_&']

Part 7 - Real-World Patterns

sorted() with operator: The Idiomatic Python Way

import operator
from dataclasses import dataclass
from typing import Sequence

@dataclass
class Product:
name: str
price: float
category: str
rating: float

products = [
Product("Keyboard", 79.99, "Electronics", 4.5),
Product("Mouse", 29.99, "Electronics", 4.2),
Product("Desk", 299.00, "Furniture", 4.8),
Product("Chair", 199.00, "Furniture", 4.6),
Product("Lamp", 39.99, "Furniture", 4.1),
]

# Sort by single field
by_price = sorted(products, key=operator.attrgetter("price"))
by_rating = sorted(products, key=operator.attrgetter("rating"), reverse=True)

# Sort by multiple fields: category (asc), then rating (desc) - tricky with operator alone
# For multi-field with mixed direction, a lambda is clearer:
by_cat_rating = sorted(
products,
key=lambda p: (p.category, -p.rating)
)

# Filter and sort together using functional tools
from functools import partial

def min_rating(product: Product, threshold: float) -> bool:
return product.rating >= threshold

high_rated_filter = partial(min_rating, threshold=4.4)
high_rated = sorted(
filter(high_rated_filter, products),
key=operator.attrgetter("price"),
)
for p in high_rated:
print(f"{p.name}: £{p.price:.2f} ({p.rating}★)")
# Keyboard: £79.99 (4.5★)
# Chair: £199.00 (4.6★)
# Desk: £299.00 (4.8★)

Django ORM Patterns and Functional Composition

Django's Q objects support a functional composition pattern similar to currying and partial application:

from functools import reduce
import operator as op

# Django ORM Q object composition (illustrative - requires Django models)
# from django.db.models import Q

# Instead of writing:
# User.objects.filter(Q(age__gte=18) | Q(parent_consented=True))

# You can build queries functionally:
def build_or_query(conditions: list) -> object:
"""Combine a list of Q objects with OR."""
from django.db.models import Q
return reduce(op.or_, conditions)

def build_and_query(conditions: list) -> object:
"""Combine a list of Q objects with AND."""
from django.db.models import Q
return reduce(op.and_, conditions)

# Usage (when Django is available):
# conditions = [Q(status="active"), Q(verified=True), Q(age__gte=18)]
# active_verified_adults = build_and_query(conditions)
# User.objects.filter(active_verified_adults)

The operator.or_ and operator.and_ functions, combined with reduce, let you build complex queries from a list of conditions - a functional pattern that avoids deeply nested Q(a) & Q(b) & Q(c) chains.

API Client with Partial for Endpoint Specialisation

from functools import partial
import urllib.request
import json
from typing import Any

def make_request(
method: str,
base_url: str,
path: str,
params: dict | None = None,
body: dict | None = None,
) -> dict:
url = f"{base_url}{path}"
if params:
query = "&".join(f"{k}={v}" for k, v in params.items())
url = f"{url}?{query}"
return {"method": method, "url": url, "body": body} # simplified

# Build specialised request functions via partial
api_base = "https://api.example.com/v1"

get = partial(make_request, "GET", api_base)
post = partial(make_request, "POST", api_base)
put = partial(make_request, "PUT", api_base)
delete = partial(make_request, "DELETE", api_base)

# Further specialise for specific resource types
get_users = partial(get, "/users")
get_user = partial(get, "/users/{id}")
create_user = partial(post, "/users")

print(get_users())
# {'method': 'GET', 'url': 'https://api.example.com/v1/users', 'body': None}

print(get_users(params={"page": 1, "limit": 20}))
# {'method': 'GET', 'url': 'https://api.example.com/v1/users?page=1&limit=20', 'body': None}

Part 8 - partialmethod for Class Method Specialisation

functools.partialmethod creates a descriptor that behaves like partial but is designed for use in class bodies - it correctly handles self.

from functools import partialmethod

class HTMLBuilder:
def _tag(self, tag: str, content: str, **attrs) -> str:
attr_str = " ".join(f'{k}="{v}"' for k, v in attrs.items())
if attr_str:
return f"<{tag} {attr_str}>{content}</{tag}>"
return f"<{tag}>{content}</{tag}>"

# Create specialised methods by pre-filling 'tag'
h1 = partialmethod(_tag, "h1")
h2 = partialmethod(_tag, "h2")
p = partialmethod(_tag, "p")
a = partialmethod(_tag, "a")
code = partialmethod(_tag, "code")

builder = HTMLBuilder()
print(builder.h1("Hello, World"))
# <h1>Hello, World</h1>

print(builder.a("Click here", href="https://example.com", class_="btn"))
# <a href="https://example.com" class_="btn">Click here</a>

print(builder.code("print('hello')"))
# <code>print('hello')</code>

partialmethod is cleaner than defining each method separately or using functools.partial at the instance level (which would require partial(self._tag, "h1") in __init__).

Common Mistakes

Mistake 1 - Positional Conflict

from functools import partial

def greet(greeting: str, name: str) -> str:
return f"{greeting}, {name}!"

# Pre-fill greeting positionally
say_hello = partial(greet, "Hello")
print(say_hello("Alice")) # Hello, Alice! ✓

# WRONG: try to override the positionally pre-filled arg
try:
say_hello("Alice", "Hi") # TypeError: too many positional arguments
except TypeError as e:
print(e)

# CORRECT: use keyword argument to override
say_hello_kw = partial(greet, greeting="Hello")
result = say_hello_kw(name="Alice") # Hello, Alice! ✓
# Now you can override at call time:
result_override = say_hello_kw(name="Alice", greeting="Hi") # Hi, Alice! ✓
print(result_override)

Mistake 2 - Partial Does Not Validate Eagerly

from functools import partial

def process(data: list, threshold: float) -> list:
return [x for x in data if x > threshold]

# Typo in keyword name - no error at definition time
wrong = partial(process, threshhold=0.5) # typo: 'threshhold'

# Error only raised when called:
try:
wrong([1, 2, 3])
except TypeError as e:
print(e) # process() got an unexpected keyword argument 'threshhold'

Mistake 3 - Confusing compose and pipe Direction

def double(x): return x * 2
def add_ten(x): return x + 10

# pipe: left-to-right - double THEN add_ten
pipe_result = pipe(double, add_ten)(5) # 5*2=10, 10+10=20
print(pipe_result) # 20

# compose: right-to-left - add_ten THEN double
compose_result = compose(double, add_ten)(5) # 5+10=15, 15*2=30
print(compose_result) # 30

# Mnemonic:
# pipe(f, g, h)(x) reads left-to-right: x goes through f, then g, then h
# compose(f, g, h)(x) reads right-to-left: x goes through h, then g, then f

Engineering Checklist

Before moving on, verify you can answer these without looking:

  1. What are the three attributes of a functools.partial object? What does each contain?
  2. Does a partial object have __name__? How do you get the original function's name?
  3. When pre-filling positional vs keyword arguments with partial, what is the difference in how they are merged at call time?
  4. What is the precise difference between partial application and currying?
  5. Python does not curry functions by default. How do you implement currying manually?
  6. What does operator.itemgetter("score") return? How does it differ from lambda x: x["score"]?
  7. What is operator.methodcaller and when is it preferable to a lambda?
  8. In pipe(f, g, h)(x), which function is applied first and which last?
  9. Why does partial not raise an error for invalid keyword argument names at definition time?

Key Takeaways

  • functools.partial(func, *args, **kwargs) returns a partial object that, when called, calls func with the pre-filled args and kwargs merged with the new arguments. Inspect it via .func, .args, .keywords.
  • partial objects do not have __name__. To get the original function's name, use partial_obj.func.__name__. If you need the wrapper to have __name__, wrap it in a functools.wraps-decorated function or use a regular function definition.
  • Pre-filling positional arguments prepends them to the argument list. Pre-filling keyword arguments merges them with call-site keywords, with call-site keywords taking precedence.
  • Partial application fixes some arguments of a function and returns a new function needing the rest - what functools.partial does. Currying transforms f(a, b, c) into f(a)(b)(c) - a chain of single-argument functions. Python does not curry automatically; implement it with a curry decorator using inspect.signature.
  • The operator module provides function equivalents for Python's operators and common access patterns: itemgetter for dict/sequence keys, attrgetter for object attributes (supports dotted names), methodcaller for method invocation with arguments. All are more readable and introspectable than equivalent lambdas.
  • functools.reduce(lambda f, g: lambda x: g(f(x)), funcs) builds a left-to-right function pipeline (pipe). functools.reduce(lambda f, g: lambda x: f(g(x)), funcs) builds a right-to-left composition (compose).
  • Prefer partial over lambda for pre-filling arguments in callbacks, sorted() key functions, and API adapters. partial is introspectable; lambda shows as <lambda> in tracebacks.
  • functools.partialmethod is partial for class bodies - it handles self correctly as a descriptor. Use it to create specialised method variants without repeating method definitions.
  • partial does not validate keyword argument names at definition time. A typo in a keyword argument name (threshhold instead of threshold) raises TypeError only when the partial object is first called - which may be much later in the program's execution.

Graded Practice

Level 1 - Predict the Output

Question 1

from functools import partial

def describe(who: str, action: str, what: str) -> str:
return f"{who} {action} {what}"

f1 = partial(describe, "Alice")
f2 = partial(describe, action="runs")
f3 = partial(f1, "jumps")

print(f1("sings", "loudly"))
print(f2("Bob", what="fast"))
print(f3("happily"))
Show Answer
Alice sings loudly
Bob runs fast
Alice jumps happily

f1 = partial(describe, "Alice") pre-fills who="Alice" positionally. f1("sings", "loudly")describe("Alice", "sings", "loudly")"Alice sings loudly".

f2 = partial(describe, action="runs") pre-fills action by keyword. f2("Bob", what="fast")describe("Bob", action="runs", what="fast")"Bob runs fast".

f3 = partial(f1, "jumps") wraps f1 (which already has "Alice" pre-filled positionally). When f3("happily") is called, it calls f1("jumps", "happily"), which calls describe("Alice", "jumps", "happily")"Alice jumps happily". Partial objects can be wrapped in further partial objects - the pre-filled args accumulate.

Question 2

import operator

data = [
("Alice", 30, "Engineering"),
("Bob", 25, "Marketing"),
("Carol", 30, "Engineering"),
("Dave", 25, "Marketing"),
]

by_age_then_name = operator.itemgetter(1, 0)
print(sorted(data, key=by_age_then_name))
Show Answer
[('Alice', 30, 'Engineering'), ('Bob', 25, 'Marketing'), ('Carol', 30, 'Engineering'), ('Dave', 25, 'Marketing')]

Wait - let me re-trace. operator.itemgetter(1, 0) returns a callable that returns (item[1], item[0]) - a tuple of (age, name). Sorting by (age, name):

  • (25, "Bob") → Bob
  • (25, "Dave") → Dave
  • (30, "Alice") → Alice
  • (30, "Carol") → Carol

Correct output:

[('Bob', 25, 'Marketing'), ('Dave', 25, 'Marketing'), ('Alice', 30, 'Engineering'), ('Carol', 30, 'Engineering')]

itemgetter(1, 0) with two indices returns a tuple (item[1], item[0]), enabling multi-level sorting. Age 25 comes before 30; within age 25, "Bob" < "Dave" alphabetically; within age 30, "Alice" < "Carol".

Question 3

from functools import partial

def power(base: float, exponent: float) -> float:
return base ** exponent

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

print(square(3))
print(cube(2))
print(two_to(10))
print(two_to(exponent=8))
Show Answer
9.0
8.0
1024.0
256.0

square(3)power(3, exponent=2)9.0. cube(2)power(2, exponent=3)8.0. two_to(10)power(2, 10) - 2 is pre-filled positionally as base, 10 is the new positional arg for exponent1024.0. two_to(exponent=8)power(2, exponent=8) - 2 pre-filled as base, exponent=8 passed as keyword → 256.0. Note that keyword arguments at call time can override/supplement positionally pre-filled args only if there is no positional conflict.

Question 4

from functools import reduce

def compose(*funcs):
return reduce(lambda f, g: lambda x: f(g(x)), funcs)

def pipe(*funcs):
return reduce(lambda f, g: lambda x: g(f(x)), funcs)

add1 = lambda x: x + 1
double = lambda x: x * 2
square = lambda x: x ** 2

f = compose(add1, double, square)
g = pipe(add1, double, square)

print(f(3))
print(g(3))
Show Answer
19
64

For compose(add1, double, square): compose is right-to-left. f(3) applies: square(3)=9, then double(9)=18, then add1(18)=19.

For pipe(add1, double, square): pipe is left-to-right. g(3) applies: add1(3)=4, then double(4)=8, then square(8)=64.

The order of functions in compose vs pipe is reversed: compose(f, g, h)(x) = f(g(h(x))) - h runs first. pipe(f, g, h)(x) = h(g(f(x))) - f runs first.

Question 5

from functools import partial
import operator

records = [
{"id": 1, "score": 80, "name": "Alice"},
{"id": 2, "score": 95, "name": "Bob"},
{"id": 3, "score": 80, "name": "Carol"},
]

get_score = operator.itemgetter("score")
get_name = operator.itemgetter("name")

def sort_key(record, primary_getter, secondary_getter):
return (primary_getter(record), secondary_getter(record))

by_score_name = partial(sort_key,
primary_getter=get_score,
secondary_getter=get_name)

result = sorted(records, key=by_score_name)
for r in result:
print(r["name"], r["score"])
Show Answer
Alice 80
Carol 80
Bob 95

by_score_name calls sort_key(record, primary_getter=get_score, secondary_getter=get_name), which returns (record["score"], record["name"]). Sorting by this tuple: Alice and Carol both have score 80, so they are sorted by name - "Alice" < "Carol". Bob has score 95, which is the highest, so he comes last. The partial here pre-fills the getter arguments to create a reusable two-level sort key function.

Level 2 - Debug Challenge

The following code is supposed to build a data transformation pipeline using partial and operator. It has two bugs - one produces incorrect output silently, and one raises an exception. Find and fix both.

from functools import partial, reduce
import operator

def clamp(value: float, min_val: float, max_val: float) -> float:
return max(min_val, min(max_val, value))

def scale(value: float, factor: float) -> float:
return value * factor

def offset(value: float, amount: float) -> float:
return value + amount

# BUG 1: wrong argument order for positional partial
clamp_0_1 = partial(clamp, 0.0, 1.0) # intends: clamp to [0.0, 1.0]

# BUG 2: pipe with wrong compose order
def pipe(*funcs):
return reduce(lambda f, g: lambda x: f(g(x)), funcs) # wrong direction

double = partial(scale, factor=2.0)
shift_up = partial(offset, amount=0.5)

transform = pipe(double, shift_up, clamp_0_1)

test_values = [-1.0, 0.3, 0.6, 2.0]
for v in test_values:
print(f"{v} -> {transform(v)}")
Show Answer

Bug 1 - Wrong positional pre-fill order for clamp:

partial(clamp, 0.0, 1.0) pre-fills value=0.0 and min_val=1.0 positionally (the first two arguments). When called as clamp_0_1(some_value), it calls clamp(0.0, 1.0, some_value) - fixing value to 0.0 and min_val to 1.0, and treating the passed value as max_val. This is not the intended behaviour.

Fix for Bug 1: Use keyword arguments to make the intent unambiguous:

clamp_0_1 = partial(clamp, min_val=0.0, max_val=1.0)

Or define a helper:

def clamp_unit(value: float) -> float:
return clamp(value, 0.0, 1.0)

Bug 2 - Wrong composition direction in pipe:

The pipe function uses lambda f, g: lambda x: f(g(x)) which is right-to-left composition (compose). For pipe(double, shift_up, clamp_0_1), this means clamp_0_1 runs first, then shift_up, then double - the reverse of the intended left-to-right order.

Fix for Bug 2: Swap f and g in the reduction:

def pipe(*funcs):
return reduce(lambda f, g: lambda x: g(f(x)), funcs)

Fixed code:

from functools import partial, reduce

def clamp(value: float, min_val: float, max_val: float) -> float:
return max(min_val, min(max_val, value))

def scale(value: float, factor: float) -> float:
return value * factor

def offset(value: float, amount: float) -> float:
return value + amount

clamp_0_1 = partial(clamp, min_val=0.0, max_val=1.0) # FIX 1: keyword args

def pipe(*funcs):
return reduce(lambda f, g: lambda x: g(f(x)), funcs) # FIX 2: left-to-right

double = partial(scale, factor=2.0)
shift_up = partial(offset, amount=0.5)

transform = pipe(double, shift_up, clamp_0_1)
# pipeline: double → shift_up → clamp_0_1

test_values = [-1.0, 0.3, 0.6, 2.0]
for v in test_values:
print(f"{v} -> {transform(v)}")

# Expected:
# -1.0: double=-2.0, shift=-1.5, clamp=0.0
# 0.3: double=0.6, shift=1.1, clamp=1.0
# 0.6: double=1.2, shift=1.7, clamp=1.0
# 2.0: double=4.0, shift=4.5, clamp=1.0

Output:

-1.0 -> 0.0
0.3 -> 1.0
0.6 -> 1.0
2.0 -> 1.0

Level 3 - Design Challenge

Design a configurable data transformation framework for a CSV processing pipeline. The framework must:

  1. Support a library of named transformation operations: strip, to_float, clamp(min, max), scale(factor), round_to(n), default(value)
  2. Allow pipeline creation from a list of operation names and parameters - no lambdas in the pipeline specification
  3. Support per-column pipeline configuration: different columns can have different transformation pipelines
  4. Handle transformation errors gracefully (invalid data returns None and records the error)
  5. Be extensible - new operations can be registered without modifying the core framework
Show Answer
from functools import partial, reduce
from typing import Any, Callable
from dataclasses import dataclass, field

# ============================================================
# OPERATION REGISTRY - extensible via register()
# ============================================================

_operations: dict[str, Callable] = {}

def register_op(name: str, func: Callable) -> None:
"""Register a named transformation operation."""
_operations[name] = func

def get_op(name: str, **kwargs) -> Callable:
"""
Get a transformation operation by name, pre-filled with parameters.
Returns a single-argument callable.
"""
if name not in _operations:
raise ValueError(f"Unknown operation: '{name}'. Registered: {list(_operations)}")
func = _operations[name]
if kwargs:
return partial(func, **kwargs)
return func

# ============================================================
# BUILT-IN OPERATIONS - all pure single-arg or configurable
# ============================================================

def _strip(value: Any) -> str:
return str(value).strip()

def _to_float(value: Any) -> float:
return float(value)

def _clamp(value: float, min_val: float, max_val: float) -> float:
return max(min_val, min(max_val, value))

def _scale(value: float, factor: float) -> float:
return value * factor

def _round_to(value: float, n: int) -> float:
return round(value, n)

def _default(value: Any, fallback: Any) -> Any:
return fallback if (value is None or str(value).strip() == "") else value

def _to_int(value: Any) -> int:
return int(float(value))

def _to_upper(value: str) -> str:
return str(value).upper()

# Register all built-in operations
register_op("strip", _strip)
register_op("to_float", _to_float)
register_op("to_int", _to_int)
register_op("clamp", _clamp)
register_op("scale", _scale)
register_op("round_to", _round_to)
register_op("default", _default)
register_op("to_upper", _to_upper)

# ============================================================
# PIPELINE BUILDER - builds pipelines from specs
# ============================================================

@dataclass(frozen=True)
class OpSpec:
"""Specification for a single operation in a pipeline."""
name: str
params: dict = field(default_factory=dict)

def build_pipeline(*op_specs: OpSpec) -> Callable:
"""
Build a left-to-right transformation pipeline from a sequence of OpSpecs.
Returns a single-argument callable.
"""
ops = [get_op(spec.name, **spec.params) for spec in op_specs]
if not ops:
return lambda x: x
return reduce(lambda f, g: lambda x: g(f(x)), ops)

# ============================================================
# ERROR-HANDLING WRAPPER - wraps a pipeline with error capture
# ============================================================

@dataclass(frozen=True)
class TransformResult:
value: Any
error: str | None

def safe_transform(pipeline: Callable, value: Any) -> TransformResult:
"""Apply a pipeline, returning a TransformResult with error capture."""
try:
return TransformResult(value=pipeline(value), error=None)
except Exception as e:
return TransformResult(value=None, error=f"{type(e).__name__}: {e}")

# ============================================================
# COLUMN PROCESSOR - applies per-column pipelines to records
# ============================================================

ColumnConfig = dict[str, tuple[OpSpec, ...]] # column_name -> op specs

def process_record(
record: dict[str, str],
column_config: ColumnConfig,
) -> dict[str, TransformResult]:
"""
Process a single record (dict of raw string values) using per-column pipelines.
Returns a dict of column_name -> TransformResult.
"""
pipelines = {
col: build_pipeline(*specs)
for col, specs in column_config.items()
}
return {
col: safe_transform(pipelines.get(col, lambda x: x), record.get(col, ""))
for col in record
}

def process_dataset(
records: list[dict[str, str]],
column_config: ColumnConfig,
) -> list[dict[str, TransformResult]]:
"""Process a full dataset."""
return [process_record(r, column_config) for r in records]

# ============================================================
# DEMO
# ============================================================

# Define per-column transformation pipelines using only OpSpecs - no lambdas
price_pipeline = (
OpSpec("default", {"fallback": "0"}),
OpSpec("strip"),
OpSpec("to_float"),
OpSpec("clamp", {"min_val": 0.0, "max_val": 10000.0}),
OpSpec("round_to", {"n": 2}),
)

quantity_pipeline = (
OpSpec("default", {"fallback": "0"}),
OpSpec("strip"),
OpSpec("to_int"),
OpSpec("clamp", {"min_val": 0, "max_val": 9999}),
)

name_pipeline = (
OpSpec("default", {"fallback": "Unknown"}),
OpSpec("strip"),
OpSpec("to_upper"),
)

column_config: ColumnConfig = {
"name": name_pipeline,
"price": price_pipeline,
"quantity": quantity_pipeline,
}

# Sample CSV data (as dicts)
raw_records = [
{"name": " widget ", "price": " 29.995 ", "quantity": "100"},
{"name": "gadget", "price": "-5.00", "quantity": "50"},
{"name": "", "price": "not_a_num", "quantity": "20"},
{"name": " Device ", "price": "99.999", "quantity": "999999"},
]

results = process_dataset(raw_records, column_config)

print(f"{'Name':<15} {'Price':<12} {'Qty':<8} {'Errors'}")
print("-" * 60)
for row in results:
name = row["name"].value or f"ERROR({row['name'].error})"
price = row["price"].value if row["price"].error is None else f"ERROR"
qty = row["quantity"].value if row["quantity"].error is None else f"ERROR"
errors = "; ".join(
f"{col}: {r.error}"
for col, r in row.items()
if r.error is not None
)
print(f"{str(name):<15} {str(price):<12} {str(qty):<8} {errors}")

Output:

Name Price Qty Errors
------------------------------------------------------------
WIDGET 30.0 100
GADGET 0.0 50
UNKNOWN None 20 price: ValueError: could not convert string to float: 'not_a_num'
DEVICE 100.0 9999

Design highlights:

  • All operations are pure functions registered in a dict - extensible without modifying the framework
  • build_pipeline(*op_specs) uses partial (via get_op) and reduce to compose operations into a single callable - no lambdas in the pipeline specification
  • OpSpec is a frozen dataclass - pipeline specs are immutable and hashable
  • safe_transform wraps any pipeline with error capture without modifying the operations themselves
  • New operations are registered with register_op("my_op", my_function) - open/closed principle
  • ColumnConfig is a type alias for a plain dict; the configuration is data, not code

What's Next

This completes the Functional Programming module. You have now covered the full functional toolkit: lambda expressions, map/filter/reduce, generators, iterators, decorators, closures, pure functions, immutability, the functools module, and partial application and currying. The next module covers Concurrency and Parallelism - where the properties you studied here (pure functions, immutability, referential transparency) become critical for writing correct concurrent Python code.

© 2026 EngineersOfAI. All rights reserved.