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
partialoverlambda: readability, introspectability, debuggability - Currying vs partial application: the precise distinction
- Implementing currying in Python manually
- The
operatormodule as curried-style operations:itemgetter,attrgetter,methodcaller - Function composition: manual implementation and
functools.reduce-based pipelines - Real-world patterns:
sorted()withoperator, Django ORMQobjects, data transformation pipelines
Prerequisites
- Lesson 09 (functools Module) -
partialwas introduced there; this lesson covers it at full depth - Lesson 06 (Closures) - currying is implemented using closures
- Lesson 02 (map/filter/reduce) -
operatorfunctions 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
# Pre-fill 'subject' and 'cc' as keyword arguments
print(notify_admin("Server Down", "All services offline"))
# {'to': '[email protected]', 'subject': 'Server Down', 'body': 'All services offline', 'cc': ''}
# {'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:
- Pre-filled positional args (
partial.args) come first - New positional args come after them
- Pre-filled keyword args (
partial.keywords) are merged with new keyword args - 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
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
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
| Property | Partial Application | Currying |
|---|---|---|
| Fills | Any subset of arguments at once | Exactly one argument per call |
| Returns | New callable taking remaining args | Single-arg function (or final result) |
| Python support | functools.partial natively | Manual implementation or third-party |
| Origin | Practical tool | Mathematical 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
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]
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:
- What are the three attributes of a
functools.partialobject? What does each contain? - Does a
partialobject have__name__? How do you get the original function's name? - When pre-filling positional vs keyword arguments with
partial, what is the difference in how they are merged at call time? - What is the precise difference between partial application and currying?
- Python does not curry functions by default. How do you implement currying manually?
- What does
operator.itemgetter("score")return? How does it differ fromlambda x: x["score"]? - What is
operator.methodcallerand when is it preferable to alambda? - In
pipe(f, g, h)(x), which function is applied first and which last? - Why does
partialnot raise an error for invalid keyword argument names at definition time?
Key Takeaways
functools.partial(func, *args, **kwargs)returns apartialobject that, when called, callsfuncwith the pre-filledargsandkwargsmerged with the new arguments. Inspect it via.func,.args,.keywords.partialobjects do not have__name__. To get the original function's name, usepartial_obj.func.__name__. If you need the wrapper to have__name__, wrap it in afunctools.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.partialdoes. Currying transformsf(a, b, c)intof(a)(b)(c)- a chain of single-argument functions. Python does not curry automatically; implement it with acurrydecorator usinginspect.signature. - The
operatormodule provides function equivalents for Python's operators and common access patterns:itemgetterfor dict/sequence keys,attrgetterfor object attributes (supports dotted names),methodcallerfor 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
partialoverlambdafor pre-filling arguments in callbacks,sorted()key functions, and API adapters.partialis introspectable;lambdashows as<lambda>in tracebacks. functools.partialmethodispartialfor class bodies - it handlesselfcorrectly as a descriptor. Use it to create specialised method variants without repeating method definitions.partialdoes not validate keyword argument names at definition time. A typo in a keyword argument name (threshholdinstead ofthreshold) raisesTypeErroronly 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 exponent → 1024.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:
- Support a library of named transformation operations:
strip,to_float,clamp(min, max),scale(factor),round_to(n),default(value) - Allow pipeline creation from a list of operation names and parameters - no lambdas in the pipeline specification
- Support per-column pipeline configuration: different columns can have different transformation pipelines
- Handle transformation errors gracefully (invalid data returns
Noneand records the error) - 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)usespartial(viaget_op) andreduceto compose operations into a single callable - no lambdas in the pipeline specificationOpSpecis a frozen dataclass - pipeline specs are immutable and hashablesafe_transformwraps any pipeline with error capture without modifying the operations themselves- New operations are registered with
register_op("my_op", my_function)- open/closed principle ColumnConfigis 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.
