Skip to content
← All articles

partial, partialmethod, and the 3.14 change that binds self

functools.partial became a method descriptor in 3.14, so a partial stored as a class attribute now binds self. 3.13 warned; 3.14 broke. The documented fix is staticmethod.

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). Use functools.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 in staticmethod — 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.