Currying in Python — Deep Dive
Currying from First Principles
In lambda calculus, every function takes exactly one argument. Multi-argument functions are syntactic sugar for nested single-argument functions. Currying formalizes this transformation:
f(x, y, z) → f(x)(y)(z)
In languages like Haskell, all functions are curried by default. Python functions are not — they accept all arguments at once. To get currying behavior, we need to build it.
Implementing a Curry Decorator
Basic Version
import inspect
from functools import wraps
def curry(fn):
"""Auto-curry: returns partial functions until all args are supplied."""
sig = inspect.signature(fn)
required = sum(
1 for p in sig.parameters.values()
if p.default is inspect.Parameter.empty
and p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
)
@wraps(fn)
def curried(*args, **kwargs):
if len(args) + len(kwargs) >= required:
return fn(*args, **kwargs)
return lambda *more_args, **more_kwargs: curried(
*args, *more_args, **{**kwargs, **more_kwargs}
)
return curried
This inspects the function signature at decoration time, counts required parameters, and returns partial closures until enough arguments accumulate.
Handling Edge Cases
The basic version has gaps:
*args and **kwargs — Functions with variadic signatures have no fixed arity. You can’t know when “all arguments are supplied.” Solution: don’t curry variadic functions, or require an explicit arity parameter.
Keyword-only arguments — These need special handling because they don’t count in positional argument accumulation:
def curry_full(fn):
sig = inspect.signature(fn)
params = sig.parameters
required_positional = sum(
1 for p in params.values()
if p.default is inspect.Parameter.empty
and p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
)
required_keyword = {
name for name, p in params.items()
if p.default is inspect.Parameter.empty
and p.kind == p.KEYWORD_ONLY
}
@wraps(fn)
def curried(*args, **kwargs):
pos_satisfied = len(args) >= required_positional
kw_satisfied = required_keyword.issubset(kwargs.keys())
if pos_satisfied and kw_satisfied:
return fn(*args, **kwargs)
return lambda *a, **kw: curried(*args, *a, **{**kwargs, **kw})
return curried
How toolz.curry Works
The toolz library’s curry is the most widely used implementation in the Python ecosystem. Key design decisions:
- Inspects
__wrapped__— If the function is already decorated (e.g., with@wraps), it looks through to find the original signature. - Caches arity — The required argument count is computed once, not on every call.
- Supports methods — It handles
selfcorrectly for class methods. - Error messages — When you supply too many arguments, it raises a clear
TypeErrorrather than silently failing.
from toolz import curry
@curry
def query(table, filters, limit, offset=0):
return f"SELECT * FROM {table} WHERE {filters} LIMIT {limit} OFFSET {offset}"
# Progressive specialization
user_query = query("users")
active_users = user_query("active = true")
page_1 = active_users(25) # All required args filled → executes
page_2 = active_users(25, offset=25) # With keyword arg
functools.partial vs Currying
| Feature | partial | curry |
|---|---|---|
| Application style | Fix specific args | One at a time (or many) |
| Automatic chaining | No — returns a partial object | Yes — returns callables until done |
| Keyword support | Full | Full |
| Variadic functions | Works (no arity check) | Problematic (no known arity) |
| Introspection | partial.func, partial.args | Varies by implementation |
| Standard library | Yes | No (needs toolz or custom) |
partial is a pragmatic tool for fixing arguments. Currying is a paradigm that changes how you think about function design.
Currying in Architecture
Configuration Injection
@curry
def send_email(smtp_config, template_engine, recipient, subject, body):
"""5-arg function curried for progressive binding."""
engine = template_engine(body)
# ... send via smtp_config
# At app startup — bind infrastructure
send = send_email(app.smtp_config, app.template_engine)
# In business logic — no infrastructure knowledge needed
send("user@example.com", "Welcome!", "Hello {{name}}")
The business logic layer never sees SMTP configuration. It receives a 3-argument function that “just sends email.”
Composable Validators
@curry
def min_length(n, field, value):
if len(value) < n:
return f"{field} must be at least {n} characters"
return None
@curry
def matches_pattern(pattern, field, value):
import re
if not re.match(pattern, value):
return f"{field} doesn't match expected format"
return None
@curry
def validate(rules, data):
errors = []
for field, validators in rules.items():
for v in validators:
err = v(field, data.get(field, ""))
if err:
errors.append(err)
return errors if errors else None
user_validator = validate({
"username": [min_length(3)],
"email": [min_length(5), matches_pattern(r".+@.+\..+")],
"password": [min_length(8)],
})
errors = user_validator({"username": "ab", "email": "bad", "password": "short"})
Each validator is curried with its threshold, then composed into a validation schema.
Curried Data Transformations
from toolz import curry, pipe
from toolz.curried import map, filter
@curry
def multiply_by(factor, x):
return x * factor
@curry
def greater_than(threshold, x):
return x > threshold
result = pipe(
range(1, 11),
map(multiply_by(3)),
filter(greater_than(10)),
list,
)
# [12, 15, 18, 21, 24, 27, 30]
Without currying, you’d need lambda x: x * 3 and lambda x: x > 10 — less readable and less reusable.
Performance Characteristics
Currying adds function call overhead per partial application:
import timeit
@curry
def add3(a, b, c):
return a + b + c
# Full call
timeit.timeit(lambda: add3(1, 2, 3), number=1_000_000) # ~0.25s
# Curried chain
timeit.timeit(lambda: add3(1)(2)(3), number=1_000_000) # ~0.75s
# Pre-applied
add_1 = add3(1)
add_1_2 = add_1(2)
timeit.timeit(lambda: add_1_2(3), number=1_000_000) # ~0.25s
The overhead comes from the intermediate function creation. Pre-applying arguments at setup time (not in hot loops) eliminates this cost at call time.
Type Annotations for Curried Functions
Typing curried functions is notoriously difficult in Python. mypy can’t track the type narrowing that happens with each partial application. Pragmatic approaches:
from typing import Callable, overload
@overload
def query(table: str) -> Callable[[str], Callable[[int], str]]: ...
@overload
def query(table: str, filters: str) -> Callable[[int], str]: ...
@overload
def query(table: str, filters: str, limit: int) -> str: ...
@curry
def query(table: str, filters: str, limit: int) -> str:
return f"SELECT * FROM {table} WHERE {filters} LIMIT {limit}"
This is verbose but gives callers correct type information at each stage. For large codebases, consider a typed wrapper that delegates to the curried implementation.
Common Pitfalls
Silent partial application — Forgetting an argument doesn’t raise an error; it silently returns a function. This can propagate through your program and surface as a confusing “function object is not subscriptable” error far from the source.
Mutable default accumulation — If curried arguments include mutable objects (lists, dicts), the partially applied function shares references. Mutations in one call affect others.
Debugging difficulty — Stack traces through curried functions show anonymous closures. Add __name__ attributes and use toolz (which preserves function metadata) over hand-rolled implementations.
Over-currying — Currying a 2-argument function used in one place adds ceremony for no benefit. Reserve currying for functions that genuinely benefit from progressive specialization.
One Thing to Remember
Currying transforms how you design functions — instead of building monolithic callables, you create flexible building blocks that specialize incrementally, making composition, configuration injection, and reuse feel natural.
See Also
- Python Function Composition Discover how snapping small Python functions together creates powerful new ones — like building words from letters.
- Python Functional Pipelines See how chaining small Python functions into a pipeline turns messy data work into a clean assembly line.
- Python Monads In Python Understand monads through a simple lunchbox analogy — no math degree required, just curiosity.
- Ci Cd Why big apps can ship updates every day without turning your phone into a glitchy mess — CI/CD is the behind-the-scenes quality gate and delivery truck.
- Containerization Why does software that works on your computer break on everyone else's? Containers fix that — and they're why Netflix can deploy 100 updates a day without the site going down.