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:

  1. Inspects __wrapped__ — If the function is already decorated (e.g., with @wraps), it looks through to find the original signature.
  2. Caches arity — The required argument count is computed once, not on every call.
  3. Supports methods — It handles self correctly for class methods.
  4. Error messages — When you supply too many arguments, it raises a clear TypeError rather 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

Featurepartialcurry
Application styleFix specific argsOne at a time (or many)
Automatic chainingNo — returns a partial objectYes — returns callables until done
Keyword supportFullFull
Variadic functionsWorks (no arity check)Problematic (no known arity)
Introspectionpartial.func, partial.argsVaries by implementation
Standard libraryYesNo (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.

pythonfunctional-programmingcurryingdesign-patterns

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.