Fair, I did not run a head-to-head test against <@ or other hypothetical new operators. However, I reviewed every major community thread on an Annotated shorthand I could find (4 of them in total). As noted in the Historical Context and Prior Art section of the PEP:
When the community debated alternatives in late 2023 and 2025, discussion trended toward the @ operator because it visually aligns with existing Python decorators.
Furthermore, as I shared in my earlier post gathering ecosystem stats, I found zero instances of __matmul__ being overloaded on types in any of the 10,000 most downloaded PyPI projects.
• • •
The PEP explicitly avoids modifying NoneType to prevent this exact issue. From the Handling of None section:
NoneType explicitly avoids implementing __matmul__ to prevent masking runtime bugs.
For example, a developer might forget to check for None before matrix multiplication: […] If NoneType.__matmul__ existed, this would silently return an AnnotatedType instead of raising a TypeError.
• • •
Adding __matmul__ to the type metaclass is not incompatible with metadata objects implementing __rmatmul__. Libraries are free to implement __rmatmul__ on their annotation objects to make them more robust at runtime. This can serve as a fallback for types like NoneType or ForwardRef objects where __matmul__ on type is not invoked.
• • •
While I originally preferred the @annot format without a space (see the “How to Teach This” section in the PEP) because of its visual similarity to decorators, I am now leaning towards @ annot (with spaces around the operator). The implementation cost for code formatters is hard to justify until we see significant adoption. They would have to maintain complex logic to format the __matmul__ operator differently depending on whether it appears inside or outside of a typing context.
Because it seems strange to me to dogmatically require Foo @ Doc("this is the description") over Foo @ "this is the description" and I believe some libraries will want to make that trade-off.
Also, I’ve worked with some libraries that have very successfully replaced named objects with string literals. I’ve not seen anyone do this in the typing-annotations context, but based on what I have seen in other contexts, I believe it could also work here.
This is an enormous strength IMO.
I’m not sure if it’s 100% true though, since Unresolved @ Doc("hello") still can’t formally resolve to Annotated[Unresolved, Doc("hello")], because in the hypothetical scenario where Unresolved implements __matmul__, their __matmul__ gets priority over __rmatmul__.
That’s always going to be the case IMO. If you want to be compliant with python 3.XX (where XX is the version where this comes out) you should implement __rmatmul__ on all your specialized annotation objects, just in case people use funny metaclasses.
It doesn’t seem like a large cost to me.
It’s a 2-liner that you can copy-paste onto a handful of class definitions.
which is a big ask. But again already part of the original proposal.
Everyone who is compliant with Python3.XX would support it. Some libraries take a little while to support the latest Python version. That’s the way of the world. But the big ones are pretty fast. And I don’t think this proposal puts undue burden on them[1]. (Neither the original version, nor the one that doesn’t modify type.)
Except in that this does put a significant burden on type-checkers. But whether that trade-off is worth it is a large part of the discussion in this whole thread. ↩︎
Bare strings should not be used for metadata objects. There’s no reasonable way to determine which tool they are to be used for (and tools should ignore metadata that isn’t for them) or if it’s actually a forwardref.
I’m reviewing the PEP again because it was submitted to the Typing Council.
Some small comments:
“Of the 10,000 most downloaded PyPI projects, 43.4% transitively depend on Annotated”. Does this mean they have a dependency on a project like Pydantic that makes use of Annotated?
" @ produces a types.AnnotatedType (a new built-in C type)." Why in the types module? It’s not technically necessary and I’d rather keep typing things only in the typing module.
" The @ operator adds nb_matrix_multiply to type and to all typing constructs that support the | union operator (types.GenericAlias, types.UnionType, types.AnnotatedType, typing.TypeVar, typing.ParamSpec, typing.TypeVarTuple, typing.TypeAliasType, typing.ForwardRef, and sentinel objects)." TypeVarTuple doesn’t actually support |. (ParamSpec maybe shouldn’t either, but it does.)
" Writing address: (str | None) @Field(...)": minor but the convention elsewhere in the PEP is a space after @.
The last thing also stood out to me while reading. The PEP currently says " Prototype integrations (beartype, pydantic, fastapi, sqlalchemy, and hypothesis) showed the existing ecosystem handles the @ operator using annotationlib’s current tools." But it does seem to be true that the PEP would make it harder for tools that only care about Annotated metadata to retrieve it, even if the type itself is undefined.
The PEP would instead give ForwardRef("undefined @ 'metadata'"), making it harder to extract the Annotated metadata. Maybe that’s the right tradeoff, but the PEP shouldn’t just dismiss this issue.
I’m also reading the PEP because it was submitted to the typing council
I agree with @Jelle that losing access to metadata on forward-reference type annotations is a real regression, which the PEP should acknowledge. (Relatedly, there is a minor contradiction in the PEP text; the section on None handling lists ForwardRef as a type which does not provide __matmul__; the very next section lists types which do provide __matmul__, and includes ForwardRef in that list.)
Some other things I noticed:
The runtime prototype changes behavior of Annotated (even the old syntax) with type variables, as an unintended consequence of the change to include the metadata in __args__. This means that the metadata is now also passed to generic handling, which causes changes like Annotated[T, T][int].__metadata__ now being (int,) instead of (T,) (specialization should not substitute in the metadata, its an opaque value), and Annotated[int, T].__parameters__ is now (T,) instead of () (metadata should not supply type parameters, even if the metadata happens to be a TypeVar at runtime). I think this is just a bug in the runtime prototype, though it might call into question whether we should change __args__. If we have to special-case Annotated either in typing.get_args or in generics, it might be just as good to leave the status quo alone and continue special-casing in get_args.
The PEP only mentions Annotated wrapping “type expressions”, but the current spec distinguishes type expressions from annotation expressions (which can wrap type expressions, and also include type qualifiers like Final, Required, etc.) And the current spec is clear that Annotated can wrap annotation expressions, not just type expressions. This should be made clearer in the PEP.
Relatedly, the reference implementations are not consistent in their handling of type qualifiers with the new syntax. For example, current type checkers (following the spec) all respect x: Annotated[NotRequired[int], "meta"] as a not-required TypedDict field, but the pyright prototype silently drops the NotRequired qualifier from the (should be) equivalent x: NotRequired[int] @ "meta". This may just be a bug in the pyright implementation, but it highlights the importance of the PEP (and the conformance suite tests which should be written based on it) being clear about the interaction with annotation expressions, not just type expressions.
Also related: dataclasses.InitVar today can appear inside Annotated, but doesn’t support __matmul__ (not listed in the LHS list in the PEP, not added in the runtime prototype) and so can’t reliably be used with the new syntax. The runtime prototype also lacks support for P.args and P.kwargs, where P is a ParamSpec – today those can be wrapped by Annotated.
I’m not sure if the parenthesized list in “Supported Left-hand Operands” is intended to be complete, but it misses many types which gain __matmul__ via _SpecialForm, _BaseGenericAlias / _GenericAlias, and NewType. It would be good to explicitly mention these.
Type aliases are not handled consistently in the reference implementations, which highlights the need to clearly specify support in the PEP (and eventual conformance suite tests). These cases include Alias = int @ "m" (not currently supported by either prototype), Alias: TypeAlias = int @ "m" (same), and type Alias = int @ "m" (supported by the pyright prototype but not the mypy one.) Other cases lacking support in mypy include cast(int @ "m", value) and a TypeVar upper bound using @.
The PEP recommends that for backwards compability with a metaclass that does provide __matmul__ for another purpose, the old bracketed special form syntax should be used. But if C is such a class, the runtime prototype currently reconstructs even Annotated["C", "meta"] using functools.reduce(operator.matmul, ...), exercising C.__matmul__ even if the old Annotated form was used.
Discussion of operator precedence should also show these examples: Annotated[int, str | float] (runtime prototype currently reprs this as int @str | float, which is misleading), T @ (m1 @ m2) (single metadata element, which is the result of m1 @ m2, unlike T @ m1 @ m2 which is two metadata elements).