2025, Nov 22 19:00

How Python decorators and PEP 612 Concatenate cause Pyright override errors with positional-only parameters

Learn why decorators using PEP 612 Concatenate make parameters positional-only, causing Pyright override errors, and how to align signatures for static typing.

Decorators that reshape a method’s parameters can collide with method override rules in static type checkers. A common symptom is Pyright flagging an override as incompatible when a decorated method accepts two parameters versus only one. The difference comes down to how parameter kinds are encoded by typing constructs and how override compatibility is enforced.

Problem

The following example decorates an overriding method and triggers a Pyright error when the method signature includes both self and a second parameter. Removing the explicit second parameter from the decorator’s typed signature makes the error disappear, which can be confusing at first glance.

from functools import wraps
from typing import Callable, Concatenate, ParamSpec, TypeVar

U = TypeVar("U")
Q = ParamSpec("Q")
S = TypeVar("S")

# Here the wrapper prepends: (self, channel)
def bind_channel(
    fn: Callable[Concatenate[U, str, Q], S]
) -> Callable[Concatenate[U, str, Q], S]:
    @wraps(fn)
    def inner(obj: U, channel: str, *a: Q.args, **kw: Q.kwargs) -> S:
        print(f"[decorator] obj={obj}, channel={channel}")
        return fn(obj, channel, *a, **kw)
    return inner


class BaseClient:
    def configure_stream(self, channel: str):
        pass


class DerivedClient(BaseClient):
    @bind_channel
    # Pyright error here
    def configure_stream(self, channel: str):
        pass

Method "configure_stream" overrides class "BaseClient" in an incompatible manner

Parameter 2 mismatch: base parameter "channel" is keyword parameter, override parameter is position-only

What’s really happening

Using Callable[Concatenate[U, str, Q], S] means the parameters U and str are prepended to the callable and are positional-only as per the semantics of PEP 612. The decorator therefore returns a function whose first two parameters are positional-only. In the base class, the channel parameter is declared without a positional-only marker, which means it may be passed positionally or by keyword. The override then narrows that parameter to positional-only, which Pyright treats as an incompatible override.

When you change the decorator to only prepend self and leave the remaining parameters in Q, the additional parameter kinds aren’t forced to positional-only. That’s why the version that effectively only hardcodes self doesn’t produce the same override error.

Fix

One way to make the override compatible is to make the base method’s second parameter positional-only so both base and override agree on parameter kinds. That aligns the method signatures and removes the incompatibility warning in Pyright. Additionally, including the positional-only marker in the wrapper signature keeps the declared intent consistent for other checkers as well.

from functools import wraps
from typing import Callable, Concatenate, ParamSpec, TypeVar

U = TypeVar("U")
Q = ParamSpec("Q")
S = TypeVar("S")

# Keep (self, channel) prepended and positional-only
def bind_channel(
    fn: Callable[Concatenate[U, str, Q], S]
) -> Callable[Concatenate[U, str, Q], S]:
    @wraps(fn)
    def inner(obj: U, channel: str, /, *a: Q.args, **kw: Q.kwargs) -> S:
        print(f"[decorator] obj={obj}, channel={channel}")
        return fn(obj, channel, *a, **kw)
    return inner


class BaseClient:
    def configure_stream(self, channel: str, /):  # positional-only
        pass


class DerivedClient(BaseClient):
    @bind_channel  # OK
    def configure_stream(self, channel: str):
        pass

An interesting side effect is that whether a call like DerivedClient().configure_stream(channel="x") is acceptable at runtime depends on the positional-only marker, while the type checker treats such a keyword use as an error when the parameter is positional-only.

Why this matters

When you use decorators with methods that participate in inheritance, you are not just changing behavior—you are also changing the method signature that type checkers see. Prepending parameters with Concatenate makes them positional-only. If the base class allows keyword use for that same parameter, the override becomes stricter than the base contract, and static analysis rightfully calls it out. Understanding this interaction prevents brittle APIs and avoids noisy, confusing diagnostics in larger codebases.

Takeaways

If a decorator uses Concatenate to add parameters in front of a method signature, those added parameters are positional-only. Ensure the base class method matches that intent by declaring the corresponding parameters as positional-only using the slash. If you keep the decorator and method aligned, Pyright will accept the override, and including the positional-only marker in the wrapper signature keeps the intent clear for other type checkers as well.