This PEP proposes overloading the @ operator on types to allow writing Annotated[T, M] as T @M.
This PEP proposes overloading the @ operator on types to allow writing
Annotated[T, M] as T @M.
PEP 593 (Annotated) has seen widespread adoption. Major frameworks
(FastAPI, Pydantic, SQLAlchemy, msgspec, Typer, beartype) now rely on it as the
standard type metadata mechanism [1].
However, its verbosity remains a barrier to adoption:
PositiveInt) to shield users from Annotated[...,
...] boilerplate [2].Annotated for tensor shapes, explicitly citing
its verbose syntax and waiting for a native language solution.Annotated for everyday documentation would make
code unreadable, contributing to the PEP’s withdrawal [5].A container-like syntax opposes Python’s evolution. Python is actively replacing
generic wrappers like typing.Union with operators like | (and the
proposed & for intersections), evolving type annotations into an arithmetic
of types.
The @ shorthand aligns syntax with semantics. By applying type metadata as a
postfix operator, it:
typing.Annotated import).This resolves the conflict between concise syntax and strict typing:
class User(BaseModel): # Bad: Early frameworks abused defaults, breaking static analysis id: int = Field(gt=0) # Verbose: PEP 593 fixed semantics, but hindered adoption id: Annotated[int, Field(gt=0)] # Proposed: Concise while preserving PEP 593 semantics id: int @Field(gt=0)
As an operator, @ composes natively:
# Inside generics list[Annotated[str, Field(max_length=50)]] list[str @Field(max_length=50)] # Within a union Annotated[int, Ge(0)] | Annotated[str, Len(5)] int @Ge(0) | str @Len(5) # Across an entire union Annotated[str | None, Field(description="Optional string")] (str | None) @Field(description="Optional string") # Within callables Callable[[Annotated[int, Ge(0)]], str] Callable[[int @Ge(0)], str]
When the community debated alternatives in late 2023 [6] and 2025 [7],
discussion trended toward the @ operator because it visually aligns with
existing Python decorators.
Adopting @ for type metadata also follows the precedent of repurposing runtime
operators for static typing, as seen with [] for generics (PEP 585) and
| for unions (PEP 604) [8].
The postfix syntax mirrors features in other statically typed languages:
[[attribute]] for types (e.g., [[nodiscard]] int f();).@Annotation (e.g., List<@NonNull String> or
val x: @NotNull String).[@attribute] postfix syntax (e.g.,
type t = int [@default 0]).To clarify discussion around annotations and metadata, the following terms apply:
x: <this>. An expression within a type hint
that evaluates to a valid type, as specified in the Typing Specification
(PEP 484).int @<this>. Data attached to a type expression
via Annotated (PEP 593, PEP 746).x: <this>. Annotations that are not intended to be evaluated as
type expressions (e.g.: ignored by type checkers).class Application { // Field Decorator (symbol decorator) @Inject private Service s; // Type Decorator (type metadata) private @NonNull String name; }
The proposed syntax uses the __matmul__ operator to attach metadata to a
type:
# Current syntax x: Annotated[int, Range(0, 10)] # Proposed shorthand x: int @Range(0, 10)
Because @ binds tighter than the | union operator (PEP 604), distinct
constraints can be attached directly to specific types within a union without
parentheses. For example, a US zip code might be a 5-digit integer or a
5-character string:
zip_code: int @Ge(10000) @Le(99999) | str @Len(5)
To attach metadata to the entire union, use parentheses:
# Attaches only to 'str' int | str @Metadata # equivalent to: int | Annotated[str, Metadata] # Attaches to the entire union (int | str) @Metadata # equivalent to: Annotated[int | str, Metadata]
Chained metadata flattens the resulting Annotated object. T @m1 @m2
evaluates to Annotated[T, m1, m2], never
Annotated[Annotated[T, m1], m2]. This mirrors typing.Annotated’s
existing runtime behavior.
This same logic applies when the left-hand operand is an existing Annotated
type:
Annotated[int, m1] @m2 # AnnotatedType(int, m1, m2) (flattened)
@ produces a types.AnnotatedType (a new built-in C type). The existing
typing.Annotated unifies with this type. typing.Annotated[X, Y] and X
@Y return the exact same object:
>>> type(int @Field()) is type(Annotated[int, Field()]) True >>> typing.Annotated is types.AnnotatedType True
An AnnotatedType object exposes the following attributes:
__origin__: The base type (e.g., int).__metadata__: A tuple of metadata items.__args__: The tuple (origin, *metadata), for compatibility with
typing.get_args().__parameters__: A tuple of unique free type parameters of the type.The repr() of an AnnotatedType uses the shorthand syntax:
>>> int @Field(gt=0) int @Field(gt=0)
None
NoneType explicitly avoids implementing __matmul__ to prevent masking
runtime bugs.
For example, a developer might forget to check for None before matrix
multiplication:
def matmul_arrays(a: np.array | None, b: np.array): return a @ b # oops, forgot to check for None
If NoneType.__matmul__ existed, this would silently return an
AnnotatedType instead of raising a TypeError.
annotationlib.Format.TYPE sidesteps this limitation in typing
expressions. It evaluates structurally, correctly parsing None @Metadata
into an AnnotatedType. Outside of type expressions, use Annotated[None,
Metadata].
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).
Applying @ to a class evaluates as type metadata; applying it to an instance
performs arithmetic.
For example, int @Field() produces an AnnotatedType, while 42 @
something raises a TypeError (or delegates to __rmatmul__).
Likewise, ndarray @Field() produces an AnnotatedType, even though
ndarray instances define __matmul__ for matrix multiplication.
Custom metaclasses can overload __matmul__ provided @ is avoided in type
expressions.
Under PEP 749’s lazy evaluation, existing annotationlib formats do not
preserve the structure of @ expressions when names are unresolvable.
Format.FORWARDREF stringifies unresolvable names:
class Model: ref: NotYetDefined @Field(gt=0)
This produces an opaque ForwardRef('"NotYetDefined" @Field(gt=0)'). The
metadata is enclosed within the string, preventing libraries from easily
inspecting it.
Format.TYPE assumes typing semantics and evaluates type expressions
structurally:
ForwardRef
independently, leaving operators intact.| operator evaluates to types.UnionType.@ operator evaluates to types.AnnotatedType.@ or the
subsequent arguments of Annotated) are evaluated as values. Unresolvable
expressions there produce a single opaque ForwardRef (identical to
Format.FORWARDREF).For example, the NotYetDefined @Field(gt=0) annotation evaluates into an
AnnotatedType where the metadata remains immediately accessible:
AnnotatedType(ForwardRef('NotYetDefined'), Field(gt=0))
This also resolves the pre-existing limitation where "Foo" | int produced
ForwardRef('Foo | int') under Format.FORWARDREF.
Annotated forces type metadata to resemble a parameterized class. A postfix
operator provides identical semantics without nesting.
Reusing the existing @ operator leaves the Python parser unchanged. The
operator’s new meaning is isolated to type evaluation, where T @M lowers to
Annotated[T, M]. Type checkers (Mypy, Pyright) also require no parser
changes, handling the syntax directly during semantic analysis. We prototyped a
Ruff conversion rule for automated migrations, and CPython prototype testing
confirms that libraries like typer and pydantic continue to work without
modification.
Writing address: (str | None) @Field(...) adds verbosity due to the lack
of native symbol decorators. Frameworks co-opt Annotated to configure
fields, even though the developer’s intent is to configure the address
field, not the str | None type. While the @ shorthand cannot fully
eliminate this structural limitation, it significantly reduces the syntactic
weight of the workaround compared to Annotated[str | None, Field(...)].
Selecting the correct operator for metadata involves balancing three considerations:
| (Union) and & (proposed
Intersection) ensures expressions like int @Field() | str parse correctly
unparenthesized. This excludes operators like |, ^, and &.Union with |), reusing an operator extends
this pattern without unnecessary churn.This leaves the set of overridable binary operators that bind tighter than
&: **, *, @, /, //, %, +, -, >>, and
<<.
Standard arithmetic operators like +, -, /, //, *, **,
and % are misleading. Reading int + x or float / Field() strongly
implies mathematical evaluation, not metadata decoration.
The remaining candidates are <<, >>, and @. We chose @ because
it already associates with metadata in Python via decorators. While libraries
like NumPy use it for matrix multiplication, it isn’t as tied to arithmetic as
operators like + or /.
The pure-Python typing._AnnotatedAlias class is replaced with a native C
implementation (types.AnnotatedType). typing.Annotated becomes a
reference to this C type rather than a special form with a custom metaclass.
To ensure a smooth transition, this legacy class is retained as a deprecated
compatibility shim. Code using isinstance(x, typing._AnnotatedAlias) will
continue to work but emit a DeprecationWarning. The shim is scheduled for
removal in Python 3.21 (see Open Issues).
Code that should be updated:
type(ann).__name__ == '_AnnotatedAlias' → use isinstance(ann,
types.AnnotatedType) or typing.get_origin(ann) is Annotatedtyping._AnnotatedAlias(origin, metadata) → use Annotated[origin,
*metadata] or origin @m1 @m2Backporting via typing_extensions: Like X | Y, the @ shorthand
requires changes to the metatype (type.__matmul__), which cannot be patched
from pure Python. The shorthand is only available on Python 3.16+. The existing
Annotated[X, Y] syntax continues to work on all supported versions and
should be used when backwards compatibility is required.
There are no direct security implications.
How to Teach ThisIn Python, the @ symbol already has an established association with metadata
through decorators. The annotation shorthand extends this intuition to the type
system: int @Field(gt=0) reads as “int, decorated with Field(gt=0).”
For beginners, the key rule is: in a type annotation, ``@`` means “with this
metadata.” For experienced developers, the mental model maps directly to
standard Python operator precedence (@ binds tighter than |).
Documentation and teaching materials should introduce the shorthand as the
primary syntax for applying metadata. The verbose typing.Annotated form
should be treated as an advanced detail, primarily relevant to library authors
or when dynamically generating types.
Visual Style: Format the shorthand as type @annot (e.g., int
@Metadata(...)), with a space before the @ and no space after it. This
distinguishes it from standard matrix multiplication (A @ B) and aligns
visually with function decorators (@decorator) and Java annotations. Code
formatters (like Ruff and Black) should enforce this spacing within typing
contexts.
Pydantic Validation: The shorthand can be used in data validation scenarios:
from pydantic import BaseModel, Field, HttpUrl from annotated_types import Len class Project(BaseModel): name: str @Field(title="Project Name") @Len(1) url: HttpUrl @Field(description="The project homepage") stars: int @Field(ge=0) = 0
FastAPI Dependency Injection: In FastAPI, the shorthand simplifies complex parameter definitions:
from fastapi import FastAPI, Header, Depends app = FastAPI() @app.get("/secure") async def secure_endpoint(token: str @Header(description="Auth token")): return {"status": "authorized"}
SQLModel and Database Definitions: SQLModel relies on Annotated to
define column properties. The shorthand syntax cleans up these definitions:
from sqlmodel import SQLModel, Field class Hero(SQLModel, table=True): id: (int | None) @Field(primary_key=True) = None name: str @Field(index=True) secret_name: str age: (int | None) @Field(index=True) = None
Testing and Formal Verification: Libraries like Hypothesis and CrossHair use
annotated-types to constrain test generation. The shorthand provides a clean
syntax for specifying test boundaries:
from dataclasses import dataclass from annotated_types import Ge, Interval from hypothesis import given @dataclass class InventoryItem: # A non-negative quantity quantity: int @Ge(0) # A price bounded between 1 and 100 price: float @Interval(gt=0, le=100) @given(...) def test_inventory(item: InventoryItem): assert item.price * item.quantity >= 0
Prototype implementations are available for the following tools:
builtins.type in Typeshed will be updated to include
def __matmul__(self, other: Any) -> types.AnnotatedType: ... to support type
checkers.
Debates around reusing @ yielded several alternatives:
int <@ Field(...)int annotated Interval(1, 10)x: int {Gt(10), Lt(20)}These lacked consensus. The @ symbol also extends cleanly to symbol
decorators if the language pursues that route later.
__rmatmul__
We explicitly reject relying on metadata objects implementing __rmatmul__
(e.g., via a base class) to return an Annotated type:
class Metadata[T = object]: def __rmatmul__(self, typ: TypeForm[T], /) -> TypeForm[T]: return Annotated[typ, self] class Le(Metadata[int]): ...
This approach is rejected for two reasons. First, relying on arbitrary
right-hand objects to implement __rmatmul__ breaks the type expression
model, complicating the assignment of fixed static meanings to operators. This
is inconsistent with how | works and contradicts the type expression grammar
defined in the specification. Second, PEP 593 explicitly permits any valid
Python object as metadata (e.g., strings, dicts). Implementing __matmul__
directly on the type metaclass avoids opt-in base classes and provides
consistent behavior.
True parameter/field decorators were rejected for now. Modifying the core parser
for symbol-level decorators opens a complex design space; this PEP scopes the
@ operator to type expressions.
We rejected the argument that @ should be avoided because of its association
with matrix multiplication. Within a type expression, Python already reuses
standard operators (like | for unions and [] for generics) with
typing-specific semantics (see Why the @ Operator?).
The design explicitly couples Format.TYPE to type expression semantics,
reinforcing the boundary between static typing and runtime evaluation:
@ operators in
annotations remain supported via Format.STRING and Format.VALUE. They
are incompatible with Format.TYPE, which enforces typing semantics.Format.TYPE handles the @ operator
structurally, it can evaluate constructs like None @Metadata even though
the global NoneType does not implement __matmul__.Although this proposal stands on its own, establishing @ for type metadata
enables several future extensions.
Developers request framework-agnostic field decorators:
class User(BaseModel): @Field(primary_key=True) id: int
Future proposals will likely need to navigate between two architectural models:
This proposal aligns with the metadata model. Type-directed libraries typically inspect static definitions during class creation rather than relying on standalone descriptors.
@Field(...) id: int would evaluate identically to id: int
@Field(...). This allows existing frameworks to inspect field configurations
and continue working unmodified. Under this model, value-space decorators modify
objects at runtime, while type-space decorators attach metadata to a type.
Future extensions to PEP 746 could support annotation targets. By
intersecting a base type with an explicit target constraint, type checkers will
validate where metadata is allowed to exist. This mitigates misuse (e.g.,
placing a @Column on a function parameter rather than a class field):
(Note: The following example assumes the ``&`` operator for intersection types has been added.)
from typing import Target class Column: """Valid only on integers that are fields of a SQLAlchemy Model.""" __supports_annotated_base__: int & Target.FIELD[SQLAlchemy.Model]
Establishing a native syntax for metadata provides a structural foundation for future extensions like annotation targets.
Open IssuesDeprecation Timeline: As a private class, typing._AnnotatedAlias could
bypass the standard 5-year deprecation policy (PEP 387). Should we
fast-track its removal?
annotationlib.Format.TYPE Extraction: Format.TYPE improves
type expression evaluation (e.g., properly resolving | unions). Does this
warrant a standalone PEP?
Thanks to Hugo van Kemenade, Jelle Zijlstra, and Eric Traut for their feedback, guidance, and assistance in refining this proposal.
References CopyrightThis document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | PEP 838: Adding python-version to pyvenv.cfg | 0 | 10.69 | 15-07-2026 |
| 2 | PEP 841: Adding Frozen Syntax to Optimize Immutable Types | 0 | 12.33 | 20-07-2026 |
| 3 | PEP 844: ``public`` and ``private`` builtins | 0 | 5.72 | 05-08-2026 |
| 4 | PEP 840: Name Resolution in Class Namespaces | 0 | 13.38 | 15-07-2026 |
| 5 | PEP 842: Module Exports | 0 | 11.23 | 25-07-2026 |
| 6 | PEP 836: JIT Go Brrr: The Path to a Supported JIT Compiler for CPython | 0 | 12.09 | 02-07-2026 |
| 7 | PEP 837: Extensible JSON serialization | 0 | 11.26 | 12-07-2026 |
| 8 | PyInfra: Infrastructure Deserves Real Code in Python, Not YAML Soup | 0 | 10 | 07-02-2026 |
| 9 | Write a coding agent from first principles: better tools | 0 | 10 | 12-07-2026 |