Deep Engineering
Advanced·Published·3.12 · 3.13 · 3.14·25 MIN

Typing decorators: why the checker goes quiet, and what fixes it

A decorator declared as Callable[..., R] lets both deliberately wrong calls through — and that is not the type checker failing, it is exactly what the ellipsis means. Meanwhile the signature is still there at runtime: inspect shows the original one. The runtime is honest and the checker is blind — and people usually expect the opposite.

Full technical treatment

TL;DR

  • A decorator declared as Callable[..., R] lets both deliberately wrong calls through, out of the three written. There are two type checkers here — mypy 2.3.1 and pyright 1.1.413 — and both stay silent; that is not either of them failing: PEP 612 says it outright — with an ellipsis, "we do no validation on arguments".
  • ParamSpec (PEP 612) on the same code produces three errors on the same two lines: two about types and one about the count. Only the decorator's declaration changes; neither its body nor the calls are touched.
  • P.args and P.kwargs exist only as a pair, and both checkers refuse half of one. The reason is in the PEP: two valid calls may split the same set of parameters differently.
  • The runtime is honest while the checker is blind — and people usually expect the opposite. inspect.signature shows the original signature, because functools.wraps left __wrapped__ behind and inspect hops along it.
  • Concatenate can only prepend positional parameters. A keyword-only one cannot be added this way, and that is a limitation of the PEP itself, not of the checkers.
  • A decorator factory is where nearly everyone gets it wrong: @trace keeps the signature, @trace(level=2) loses it silently. The cure is a protocol with a generic __call__.

Zero errors on two wrong calls

Start with the measurement, not the explanation. Here is an ordinary decorator, written the way they are most often written:

PYTHON
def add_logging(f: Callable[..., R]) -> Callable[..., R]:
    def inner(*args: object, **kwargs: object) -> R:
        return f(*args, **kwargs)
 
    return inner
 
 
@add_logging
def takes_int_str(x: int, y: str) -> int:
    return x + 7

And three calls under it, two of them deliberately wrong:

PYTHON
takes_int_str(1, "A")  # a correct call
takes_int_str("B", 2)  # the types are swapped
takes_int_str()  # no arguments at all

The type checker — mypy 2.3.1 — finds none of them. The file in full is bench/typing/01_naive.py; the output verbatim:

$ mypy 01_naive.py
  -> exit=0

No error, no warning, exit code zero. The second checker, pyright 1.1.413, is silent on the same file too.

What the ellipsis means

The usual explanation — "... means any arguments" — sounds harmless, which is why it does not put anyone on guard. The typing documentation phrases it that way:

If a literal ellipsis ... is given as the argument list, it indicates that a callable with any arbitrary parameter list would be acceptable.

typing — Support for type hints

About what happens to the checking of CALLS to such an object, it says nothing. PEP 612 does say it, and in rather different words:

This was not caught by the type checker because the decorated takes_int_str was given the type Callable[..., Awaitable[int]] (an ellipsis in place of parameter types is specified to mean that we do no validation on arguments).

PEP 612 — Parameter Specification Variables

The gap between "any is acceptable" and "we do no validation" is exactly the gap between a harmless-sounding phrase and three calls let through. The typing specification puts it more strictly: This is a gradual form indicating that the type is consistent with any input signature. A gradual form is a place where checking is deliberately switched off.

Notice what this explanation does not contain: the word "bug". The checker did exactly what it was told. It was told badly.

ParamSpec: the same code, three errors

PEP 612 exists precisely to fix this. Its problem statement is about decorators in as many words:

Neither of these support forwarding the parameter types of one callable over to another callable, making it difficult to annotate function decorators.

PEP 612, Abstract

The same decorator, rewritten with a ParamSpec in PEP 695 syntax:

PYTHON
def add_logging[**P, R](f: Callable[P, R]) -> Callable[P, R]:
    def inner(*args: P.args, **kwargs: P.kwargs) -> R:
        return f(*args, **kwargs)
 
    return inner

The decorator's body has not changed. The calls have not changed. The file of calls is the same file. One line changed — the declaration. The output (bench/typing/02_paramspec.py):

02_paramspec.py:22: error: Argument 1 to "takes_int_str" has incompatible type "str"; expected "int"  [arg-type]
02_paramspec.py:22: error: Argument 2 to "takes_int_str" has incompatible type "int"; expected "str"  [arg-type]
02_paramspec.py:23: error: Missing positional arguments "x", "y" in call to "takes_int_str"  [call-arg]

Three errors on the same two lines — two about types and one about the count. pyright finds the same three, in its own words. This article, in essence, comes down to that pair of runs: ... is not "any arguments", it is "do not check".

Why P.args and P.kwargs come only as a pair

The first thing people trip over is writing half of it:

PYTHON
def half[**P, R](f: Callable[P, R]) -> Callable[P, R]:
    def inner(*args: P.args) -> R:  # error: half of a pair
        return f(*args)
 
    return inner

Both checkers refuse, each in its own words (bench/typing/07_args_kwargs_pair.py):

07_args_kwargs_pair.py:26: error: ParamSpec must have "*args" typed as "P.args" and "**kwargs" typed as "P.kwargs"  [valid-type]

And two more follow, on lines 27 and 29: the call is left without parameters, and the result no longer fits the declared type. The second checker refuses on the same three lines and names the rule more directly:

07_args_kwargs_pair.py:26: "args" and "kwargs" attributes of ParamSpec must both appear within a function signature

The reason is named in the PEP, and it is not about notational convenience:

A ParamSpec captures both positional and keyword accessible parameters, but there unfortunately is no object in the runtime that captures both of these together.

PEP 612, The components of a ParamSpec

Then comes the point of the restriction. Different calls split the same set of parameters differently: f(1, y=2) puts the one into args and the two into kwargs, while f(1, 2) puts both into args.

Therefore, we need to make sure that these special types are only brought into the world together, and are used together, so that our usage is valid for all possible partitions.

Ibid.

So the pair is not a syntactic formality but the only way to say "the very same parameters" without knowing in advance how the caller will split them.

The runtime is honest, the checker is blind

Here begins the part that is written down almost nowhere, and it inverts the picture people carry.

While the checker was silent, the signature never went anywhere at runtime. Measured on 3.12.3, 3.13.7 and 3.14.7 — identical on all three:

inspect.signature(takes_int_str)                       -> (x: int, y: str) -> int
inspect.signature(takes_int_str, follow_wrapped=False) -> (*args, **kwargs) -> int
takes_int_str.__code__.co_varnames                     -> ('args', 'kwargs')

Read it from the bottom up. The last line is the truth: under the decorator sits a function taking *args, **kwargs, and that is the one that will be called. The middle line is the same truth, spoken by inspect when asked not to hop. The top line is what inspect shows by default.

The hop is made possible by functools.wraps — the mechanism itself is taken apart in the article on decorators, and here it is taken as given. wraps puts a reference to the wrapped function on the wrapper (wrapper.__wrapped__ = wrapped, Lib/functools.py:62 on 3.13.7), and inspect.signature is declared to follow it: def signature(obj, *, follow_wrapped=True, ...) (Lib/inspect.py:3373).

That is where the belief that "functools.wraps fixes everything" comes from. It fixes introspection, help() and everything that reads __wrapped__. It tells the checker nothing: wraps is code that runs at decoration time, and the checker never gets as far as running anything.

The result is a picture rare in Python: the runtime tool knows the truth, and the tool whose whole job is to warn you in advance does not. Usually it is the other way round, and the mistake lives on that expectation.

When the decorator adds an argument

ParamSpec describes "the very same parameters". But a decorator often changes the signature: it supplies something itself and does not expose it outward. For those cases PEP 612 introduces Concatenate:

The semantics of Concatenate[X, Y, P] are that it represents the parameters represented by P with two positional-only parameters prepended.

PEP 612, Concatenate

A decorator that supplies a database connection and hides it from the outside (bench/typing/03_concatenate_and_protocol.py):

PYTHON
def needs_conn[**P, R](f: Callable[Concatenate[str, P], R]) -> Callable[P, R]:
    def inner(*args: P.args, **kwargs: P.kwargs) -> R:
        return f("conn", *args, **kwargs)
 
    return inner
 
 
@needs_conn
def query(conn: str, sql: str, limit: int) -> int:
    return limit

From the outside query keeps two parameters rather than three, and that is checked:

the callthe verdict
query("select", 10)accepted: conn was supplied by the decorator
query(10, "select")two arg-type errors — the types are in the wrong order
query("select", 10, 1)call-arg: Too many arguments for "query"

The decorator's input type and output type are now different, and that is the whole point: Callable[Concatenate[str, P], R] going in, Callable[P, R] coming out.

What Concatenate cannot do

There is no symmetric way to add a keyword parameter. This is not a gap in the checkers, nor something "mypy has not got round to" — the limitation is written into the PEP as its own subsection, together with the reason:

However, the key distinction is that while prepending positional-only parameters to a valid callable type always yields another valid callable type, the same cannot be said for adding keyword-only parameters.

PEP 612, Concatenating Keyword Parameters

The PEP closes the subsection promising to return to the question if there is sufficient demand. Apparently the demand has not built up: nothing changed in 3.14.

The practical conclusion is simple. A decorator that adds a keyword parameter can only be typed with a protocol whose __call__ spells the parameters out by hand, without a ParamSpec.

Decorating a method: Self survives

The common line "chained calls break after a decorator" can be checked the same way. Concatenate[S, P] preserves both the receiver and Self (bench/typing/06_methods.py):

PYTHON
def logged[S, **P, R](m: Callable[Concatenate[S, P], R]) -> Callable[Concatenate[S, P], R]:
    def inner(self: S, *args: P.args, **kwargs: P.kwargs) -> R:
        return m(self, *args, **kwargs)
 
    return inner
 
 
class A:
    @logged
    def m(self, x: int) -> str:
        return str(x)
 
    @logged
    def chain(self) -> Self:
        return self

The output:

06_methods.py:24: note: Revealed type is "def (x: int) -> str"
06_methods.py:25: note: Revealed type is "06_methods.A"
06_methods.py:26: error: Argument 1 to "m" of "A" has incompatible type "str"; expected "int"  [arg-type]

First line: on a.m the receiver is already bound, and one parameter is visible from outside. Second: a.chain() gives A, so Self made it through the decorator. Third: a wrong call to a method is caught just like a wrong call to a function.

When the decorator attaches an attribute

Callable[P, R] describes something callable, and nothing else. If a decorator adds an attribute to the object (cache_clear, a counter, anything), that type has no room for it. The answer is a protocol that has both a __call__ and everything else (the second half of bench/typing/03_concatenate_and_protocol.py):

PYTHON
class HasCache[**P, R](Protocol):
    __wrapped__: Callable[P, R]
 
    def cache_clear(self) -> None: ...
    def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: ...
 
 
def memo[**P, R](f: Callable[P, R]) -> HasCache[P, R]: ...

Then h.cache_clear() passes, and h("x") on an h(a: int) is an error — one that arrives with an address: Argument 1 to "__call__" of "HasCache". Note that P.args and P.kwargs behave inside a protocol exactly as they do in an ordinary function: the pair stays a pair.

The factory: where nearly everyone gets it wrong

A decorator that supports both forms — @trace and @trace(level=2) — is written as a pair of overloads, and it looks impeccable:

PYTHON
@overload
def trace[**P, R](f: Callable[P, R], /) -> Callable[P, R]: ...
@overload
def trace(*, level: int = ...) -> Callable[[Callable[..., object]], Callable[..., object]]: ...

The output on two functions decorated by the different forms (bench/typing/04_factory_broken.py):

04_factory_broken.py:30: note: Revealed type is "def (x: int) -> str"
04_factory_broken.py:31: note: Revealed type is "def (*Any, **Any) -> object"
04_factory_broken.py:32: error: Argument 1 to "a" has incompatible type "str"; expected "int"  [arg-type]

The first line is @trace, signature intact. The second is @trace(level=2), signature gone. The third is the error on a("bad"). And one line below sits b("bad"), a wrong call in exactly the same way, and it is not flagged at all.

The cause is visible in the second overload. A factory has to return one type, while a ParamSpec has to bind afresh at every application of the decorator. A single Callable[[Callable[..., object]], Callable[..., object]] cannot do that — and the ellipsis inside it does precisely what this article opened with.

The cure is a factory that returns not a Callable but a protocol with a generic __call__ (bench/typing/05_factory_fixed.py):

PYTHON
class _Deco(Protocol):
    def __call__[**P, R](self, f: Callable[P, R], /) -> Callable[P, R]: ...
 
 
@overload
def trace[**P, R](f: Callable[P, R], /) -> Callable[P, R]: ...
@overload
def trace(*, level: int = ...) -> _Deco: ...

The type parameters have moved from the function onto __call__ itself, and now they bind at the moment of application. The output:

05_factory_fixed.py:34: note: Revealed type is "def (x: int) -> str"
05_factory_fixed.py:35: note: Revealed type is "def (x: int) -> str"
05_factory_fixed.py:36: error: Argument 1 to "b" has incompatible type "str"; expected "int"  [arg-type]

Both forms give the same signature, and b("bad") is finally caught.

One observation along the way, which cost us a correction of our own. The implementation under @overload originally had no annotations (def trace(f=None, *, level=1):). mypy does not look at such an implementation at all; pyright produced two errors each time, "Overloaded implementation is not consistent with signature of overload". Annotating the implementation removed both and changed nothing in the example's conclusions. An unannotated overload implementation is invisible to mypy — worth remembering well beyond decorators.

Version history

VersionChangeWhat this means for your code
3.10PEP 612: ParamSpec and Concatenate arrive. Before that there was nothing to express a signature-preserving decorator with, other than a callback protocol with its parameters written out by hand.
3.12PEP 695: the syntax def deco[**P, R] instead of a separate P = ParamSpec("P"). Measured: from 3.12, typing.ParamSpec is literally _typing.ParamSpec, a type from a module built into the interpreter (typing.ParamSpec is _typing.ParamSpec gives True, and _typing has no __file__). On 3.11, _typing has no ParamSpec attribute at all.
3.13PEP 696: type parameters gain defaults, ParamSpec included. Measured: TypeVar(..., default=int) raises TypeError on 3.12 and def f[T = int] is a SyntaxError there; on 3.13 both work.
3.14PEP 649: annotations are evaluated lazily. The practical gift for decorators is that a protocol describing the wrapper can be declared below the decorator that returns it, with no quotes and no from __future__ import annotations. Measured: on 3.13 such a definition raises NameError on the def line itself; on 3.14 it goes through, and annotationlib hands back a ForwardRef.

What measured this

The findings in this article come from these files. Each one opens right from here, together with the record of the run: what checked it, with which command, and what came out verbatim.

There are two checkers: mypy 2.3.1 and pyright 1.1.413, both under CPython 3.13.7. They agreed on all seven files — same lines, same counts, same conclusions. That is worth naming: what a tool prints is an observation here, not a source. The typing specification comes first, and wherever this article leans on a rule rather than on an observation, a quotation from the specification or from a PEP stands next to it.

The version matrix comes from a single file run on four interpreters: 3.11.15, 3.12.3, 3.13.7 and 3.14.7 — all four final releases, not release candidates. 3.11 is not there for completeness: it is the only one that shows ParamSpec has not always lived in C.

There are no timings in this article, deliberately. Typing does not affect speed, and any table of nanoseconds here would be a table about PEP 649 rather than about ParamSpec.

Common misconceptions

Claim

functools.wraps fixes the types

Actually

It fixes introspection, and only that. wraps sets wrapper.__wrapped__ = wrapped (Lib/functools.py:62), and inspect.signature hops along that reference because it is declared with follow_wrapped=True (Lib/inspect.py:3373). It tells the checker nothing: wraps runs at decoration time, and the checker never gets as far as running anything. Measured on bench/typing/01_naive.py: both deliberately wrong calls go through in silence. There is no wraps in that file — which is the point: it would have changed nothing.

Claim

Callable[..., R] means “any arguments”

Actually

It means “do not check”. The typing documentation puts it gently (“a callable with any arbitrary parameter list would be acceptable”); PEP 612 puts it outright: an ellipsis in place of parameter types is specified to mean that we do no validation on arguments. Measured on bench/typing/01_naive.py: zero errors, exit code 0.

Claim

Chained calls break after a decorator: Self is lost

Actually

It is not lost, provided the decorator is declared with Concatenate[S, P]. Measured on bench/typing/06_methods.py: for a class A with decorated methods, reveal_type(a.m) gives def (x: int) -> str — the receiver is bound — and reveal_type(a.chain()) gives A. Both checkers agree.

Claim

If @deco is typed correctly then @deco(level=2) is too

Actually

This is the costliest spot in the topic. Measured: with an ordinary pair of overloads, @trace gives def (x: int) -> str while @trace(level=2) gives def (*Any, **Any) -> object. The loss is silent: a("bad") is flagged, b("bad") one line below is not. The cause is that a factory has to return one type while a ParamSpec has to bind afresh at every application. The cure is a protocol with a generic __call__.

Claim

ParamSpec with Concatenate can add a keyword parameter through a decorator

Actually

It cannot, and this is a limitation of PEP 612 itself rather than of the checkers. The semantics of Concatenate[X, Y, P] are that it represents the parameters represented by P with two positional-only parameters prepended. The PEP gives keyword-only parameters their own subsection explaining why this does not work, and closes it promising to revisit the question “if there is sufficient demand”. In 3.14 it was not revisited.

Check yourself

Question 1 of 4

A decorator is declared as Callable[..., R] -> Callable[..., R], with takes_int_str(x: int, y: str) underneath it. How many errors will the checker report on takes_int_str("B", 2) and takes_int_str()?

Sources & further reading

9 SOURCES

  1. PEP 612 — Parameter Specification VariablesPEP. Mark Mendoza, Final, Python 3.10. The document that brought `ParamSpec` and `Concatenate` into the language, and the only place that states outright what the ellipsis means: “an ellipsis in place of parameter types is specified to mean that we do no validation on arguments”. The problem statement is about decorators in as many words: “Neither of these support forwarding the parameter types of one callable over to another callable, making it difficult to annotate function decorators”. The pair rule: “A ParamSpec captures both positional and keyword accessible parameters, but there unfortunately is no object in the runtime that captures both of these together”, and the conclusion drawn from it: “we need to make sure that these special types are only brought into the world together, and are used together, so that our usage is valid for all possible partitions”. And the semantics of concatenation: “The semantics of Concatenate[X, Y, P] are that it represents the parameters represented by P with two positional-only parameters prepended”.https://peps.python.org/pep-0612/
  2. Typing specification — CallablesOfficial documentation. More primary than the behaviour of any checker. On the ellipsis: “The Callable special form supports the use of `...` in place of the list of parameter types. This is a gradual form indicating that the type is consistent with any input signature”. And that it composes with concatenation: “A `...` can also be used with Concatenate”.https://typing.python.org/en/latest/spec/callables.html
  3. Typing specification — GenericsOfficial documentation. The `ParamSpec` section opens by naming its origin — “(Originally specified by PEP 612.)” — and reproduces the PEP's wording verbatim: both the rule that `P.args` and `P.kwargs` are used together, and “Placing keyword-only parameters between the *args and **kwargs is forbidden”. So both of this article's key claims rest on normative text, not only on what a tool prints.https://typing.python.org/en/latest/spec/generics.html
  4. PEP 695 — Type Parameter SyntaxPEP. Final, Python 3.12. The syntax every decorator in this article is written in: “The syntax adds support for a comma-delimited list of type parameters in square brackets after the name of the class, function, or type alias”. The double star for a `ParamSpec` is part of the grammar: `type_param: | a=NAME b=[type_param_bound] | '*' a=NAME | '**' a=NAME`.https://peps.python.org/pep-0695/
  5. PEP 696 — Type Defaults for Type ParametersPEP. Final, Python 3.13. “This PEP introduces the concept of type defaults for type parameters, including TypeVar, ParamSpec, and TypeVarTuple, which act as defaults for type parameters for which no type is specified.” Used here as a version boundary: on 3.12 the syntax `def f[T = int]` is a SyntaxError and `TypeVar(..., default=int)` a TypeError; on 3.13 both work.https://peps.python.org/pep-0696/
  6. PEP 649 — Deferred Evaluation Of Annotations Using DescriptorsPEP. Final, Python 3.14. The mechanism: “It adds a new internal mechanism for lazily computing annotations on demand, via a new object method called `__annotate__`”, and what follows from it: “This mechanism delays the evaluation of annotations expressions until the annotations are examined, which solves many circular reference problems”. That is what makes it possible to declare a protocol below the decorator that returns it.https://peps.python.org/pep-0649/
  7. Lib/functools.py — update_wrapperCPython source code. The line that keeps the runtime honest: `wrapper.__wrapped__ = wrapped` (`Lib/functools.py:62` on 3.13.7), with a comment next to it explaining why it comes last: “Issue #17482: set __wrapped__ last so we don't inadvertently copy it from the wrapped function when updating __dict__”. The list of copied attributes is `WRAPPER_ASSIGNMENTS` on line 33.https://github.com/python/cpython/blob/v3.13.7/Lib/functools.py
  8. Lib/inspect.py — signature and unwrapCPython source code. The default that makes `inspect.signature` show a function other than the one that will be called: `def signature(obj, *, follow_wrapped=True, ...)` (`Lib/inspect.py:3373` on 3.13.7). The hop along the chain is done by `unwrap` (line 764). Verified on 3.12.3, 3.13.7 and 3.14.7: with the default you see the original signature, with `follow_wrapped=False` you see `(*args, **kwargs)`.https://github.com/python/cpython/blob/v3.13.7/Lib/inspect.py
  9. typing — Support for type hintsOfficial documentation. The documentation puts the ellipsis more gently than the PEP does: “If a literal ellipsis `...` is given as the argument list, it indicates that a callable with any arbitrary parameter list would be acceptable”. There is no sentence on that page saying the arguments then go unchecked — that one is in PEP 612, and the gap between “any is acceptable” and “we do no validation” is precisely the subject of this article's first section.https://docs.python.org/3/library/typing.html