functools.partial(fn, *args, **kwargs) freezes some arguments and returns a
callable taking the rest. It is the well-behaved cousin of a lambda: it keeps
a reference to the underlying function in .func, its frozen arguments are
introspectable as .args and .keywords, and — the property that matters in
production — it is picklable, which a lambda is not.
That single difference decides most real choices between them.
multiprocessing, concurrent.futures.ProcessPoolExecutor, and anything that
ships a callable across a process boundary require a picklable object. A
lambda raises; a partial of a module-level function goes through.
Caller-supplied keywords override the frozen ones:
connect = partial(open_socket, timeout=5.0)
connect(host) # timeout=5.0
connect(host, timeout=0.5) # timeout=0.5 -- caller wins
Frozen positional arguments do not work that way: they are prepended, so
the caller’s positionals come after. If you need to reserve a positional slot
in the middle, 3.14 added functools.Placeholder.
A partial has no __name__, and functools.wraps does not apply to it — so
logging or error messages that reach for fn.__name__ need fn.func.__name__
or a getattr fallback.
The 3.14 breaking change
Before 3.14, a partial stored as a class attribute did not bind self:
class Notifier:
def send(self, body: str, *, level: str = "info") -> str: ...
alert = partial(send, level="alert") # pre-3.14: does not bind
In 3.14, functools.partial became a method descriptor, so accessing it
through an instance binds self like a normal function would. 3.13 emitted a
FutureWarning about the coming change.
This cuts both ways, and which way depends on what you meant:
-
You wanted the partial to be a method (frozen keyword, still receives
self). Usefunctools.partialmethod, which has done exactly that correctly on every version. -
You wanted a free function parked in the class namespace as a helper (does
not receive
self). Wrap it instaticmethod— this is the documented fix for preserving the pre-3.14 behaviour.
Either way, do not rely on the bare partial-as-class-attribute. It means one
thing on 3.13 and the other on 3.14, and the difference is a TypeError about
argument counts several layers from the definition.
💡Why is a bare partial class attribute so hard to spot in a
click to reveal
review, even knowing about the change? Because the two behaviours differ only at the access site, and only through an instance.
Notifier.banner("x") behaves identically on both versions — accessing
through the class gives you the partial itself. notifier.banner("x") is the
one that differs, and it looks exactly the same in a diff.
Worse, the failure is a TypeError about too many positional arguments,
raised inside the wrapped function. The traceback names shout, not
Notifier, so a reader chases the wrong function. And it will not reproduce
on a colleague’s 3.13 machine.
The mechanical rule that avoids the whole question: never put a bare partial
in a class body. partialmethod if it is a method, staticmethod(partial(...))
if it is not. Both are explicit about which you meant.
“partial is untypeable” is stale advice
mypy has type-checked partial application since 1.11 (July 2024). It
verifies the frozen arguments against the target’s signature and gives you a
correctly-typed callable for the rest. If you learned to avoid partial
because it defeated the checker, that reason has expired.
💡make_retrier(fn, *, attempts, delay) should return "a callable
click to reveal
with the same signature as fn“. How do you say that in the type system, and
what does the naive version cost you?
With ParamSpec:
P = ParamSpec("P")
R = TypeVar("R")
def make_retrier(fn: Callable[P, R], *, attempts: int) -> Callable[P, R]:
def retrying(*args: P.args, **kwargs: P.kwargs) -> R:
...
return retrying
P captures the whole parameter list — positional, keyword, defaults — and
reproduces it on the wrapper, so every call site of the retried function is
checked exactly as if the decorator were not there.
The naive Callable[..., Any] version costs you two things. Every call to the
wrapped function becomes unchecked, so a typo in a keyword argument at a call
site is no longer an error. And the return becomes Any, which then
propagates into whatever the caller assigns it to, silently disabling
checking downstream. Callable[..., Any] does not merely fail to help; it
actively erases checking that existed before you added the decorator.