PEP 835 – Shorthand syntax for Annotated type metadata
- Author:
- Till Varoquaux <till.varoquaux at gmail.com>
- Sponsor:
- Ivan Levkivskyi <levkivskyi at gmail.com>
- Discussions-To:
- Discourse thread
- Status:
- Draft
- Type:
- Standards Track
- Topic:
- Typing
- Created:
- 12-Jun-2026
- Python-Version:
- 3.16
- Post-History:
- 19-Apr-2026, 18-Jun-2026
Abstract
This PEP proposes overloading the @ operator on types to allow writing
Annotated[T, M] as T @M.
Motivation
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:
- Libraries invent aliases to hide it. Frameworks routinely create DSLs or
type aliases (e.g.,
PositiveInt) to shield users fromAnnotated[..., ...]boilerplate [2]. - Major projects defer adoption. PyTorch [3], TorchTyping, and Beartype
[4] deferred adopting
Annotatedfor tensor shapes, explicitly citing its verbose syntax and waiting for a native language solution. - It impacts readability. During the PEP 727 discussions, participants
warned that relying on
Annotatedfor 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:
- Reduces nesting (no brackets required).
- Reduces tokens (no
typing.Annotatedimport). - Improves locality (the base type remains front-and-center).
- Extends the arithmetic of types (type metadata acts as an operation on an existing type expression rather than a generic container).
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]
Historical Context and Prior Art
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:
- C++: Uses
[[attribute]]for types (e.g.,[[nodiscard]] int f();). - Java / Kotlin: Uses
@Annotation(e.g.,List<@NonNull String>orval x: @NotNull String). - OCaml: Uses
[@attribute]postfix syntax (e.g.,type t = int [@default 0]).
Terminology
To clarify discussion around annotations and metadata, the following terms apply:
- Type expression: e.g.,
x: <this>. An expression within a type hint that evaluates to a valid type, as specified in the Typing Specification (PEP 484). - Type metadata: e.g.,
int @<this>. Data attached to a type expression viaAnnotated(PEP 593, PEP 746). - Non-typing annotation: e.g., @no_type_check
wrapping
x: <this>. Annotations that are not intended to be evaluated as type expressions (e.g.: ignored by type checkers). - Symbol decorator: Applying metadata directly to a variable or field
declaration, rather than to its underlying type. This distinction is
well-established in languages like Java:
class Application { // Field Decorator (symbol decorator) @Inject private Service s; // Type Decorator (type metadata) private @NonNull String name; }
- Symbol metadata (or field metadata): Metadata attached to a symbol. It is distinct from type metadata, as it applies to the specific instance of the variable rather than the type itself.
Specification
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)
Operator Precedence
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]
Flattening Multiple 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)
Runtime Behavior
@ 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 withtyping.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)
Handling of 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].
Supported Left-Hand Operands
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.
Forward References and Deferred Evaluation
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:
- Unresolvable Names: Unresolvable names are wrapped in a
ForwardRefindependently, leaving operators intact. - Union Types: The
|operator evaluates totypes.UnionType. - Annotated Types: The
@operator evaluates totypes.AnnotatedType. - Metadata Boundary: Structural evaluation applies only to the type space.
Expressions within the metadata (the right-hand side of
@or the subsequent arguments ofAnnotated) are evaluated as values. Unresolvable expressions there produce a single opaqueForwardRef(identical toFormat.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.
Rationale
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.
Parentheses and Verbosity
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(...)].
Why the @ Operator?
Selecting the correct operator for metadata involves balancing three considerations:
- Precedence: Binding tighter than
|(Union) and&(proposed Intersection) ensures expressions likeint @Field() | strparse correctly unparenthesized. This excludes operators like|,^, and&. - Ecosystem Compatibility: New keywords require ecosystem-wide parser
migrations. Because Python’s type system is evolving into an arithmetic of
types (e.g., replacing
Unionwith|), reusing an operator extends this pattern without unnecessary churn. - Semantic Clarity: The chosen syntax should avoid colliding with established intuitions for primitive types.
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 /.
Backwards Compatibility
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'→ useisinstance(ann, types.AnnotatedType)ortyping.get_origin(ann) is Annotatedtyping._AnnotatedAlias(origin, metadata)→ useAnnotated[origin, *metadata]ororigin @m1 @m2
Backporting 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.
Security Implications
There are no direct security implications.
How to Teach This
In 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.
Usage Examples
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
Reference Implementation
Prototype implementations are available for the following tools:
- CPython: CPython at-type-annot
- CPython (with annotation-lib structural forward references): CPython forward-stringifier
- Mypy: Mypy at-type-annot
- Mypyc/ast_serialize: ast_serialize at-type-annot
- Pyright: Pyright at-type-annot
- Ruff: Ruff at-type-annot
builtins.type in Typeshed will be updated to include
def __matmul__(self, other: Any) -> types.AnnotatedType: ... to support type
checkers.
Rejected Ideas
Alternative Syntaxes
Debates around reusing @ yielded several alternatives:
- A new infix operator:
int <@ Field(...) - A new soft keyword:
int annotated Interval(1, 10) - Bracket syntax:
x: int {Gt(10), Lt(20)}
These lacked consensus. The @ symbol also extends cleanly to symbol
decorators if the language pursues that route later.
Reliance on __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.
Parameter/Field Decorators
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.
Avoiding @ Due to Matrix Multiplication
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?).
Evaluating Type Expressions as Runtime Code
The design explicitly couples Format.TYPE to type expression semantics,
reinforcing the boundary between static typing and runtime evaluation:
- Non-Typing Annotations: Frameworks using custom
@operators in annotations remain supported viaFormat.STRINGandFormat.VALUE. They are incompatible withFormat.TYPE, which enforces typing semantics. - Structural Evaluation: Because
Format.TYPEhandles the@operator structurally, it can evaluate constructs likeNone @Metadataeven though the globalNoneTypedoes not implement__matmul__.
Future Work
Although this proposal stands on its own, establishing @ for type metadata
enables several future extensions.
Native Symbol Decorators
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:
- The descriptor model: The decorator acts as a runtime function returning a descriptor, actively intercepting attribute access (similar to standard Python function decorators).
- The metadata model: The field configuration is treated as passive symbol metadata, leaving the enclosing class to process it during creation.
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.
Targeted Metadata
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 Issues
Deprecation 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?
Acknowledgements
Thanks to Hugo van Kemenade, Jelle Zijlstra, and Eric Traut for their feedback, guidance, and assistance in refining this proposal.
References
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.