Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python Enhancement Proposals

PEP 835 – Shorthand syntax for Annotated type metadata

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

Table of Contents

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:

  1. Libraries invent aliases to hide it. Frameworks routinely create DSLs or type aliases (e.g., PositiveInt) to shield users from Annotated[..., ...] boilerplate [2].
  2. Major projects defer adoption. PyTorch [3], TorchTyping, and Beartype [4] deferred adopting Annotated for tensor shapes, explicitly citing its verbose syntax and waiting for a native language solution.
  3. It impacts readability. During the PEP 727 discussions, participants warned that relying on 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:

  • Reduces nesting (no brackets required).
  • Reduces tokens (no typing.Annotated import).
  • 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> or val 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 via Annotated (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 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)

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 ForwardRef independently, leaving operators intact.
  • Union Types: The | operator evaluates to types.UnionType.
  • Annotated Types: The @ operator evaluates to types.AnnotatedType.
  • Metadata Boundary: Structural evaluation applies only to the type space. Expressions within the metadata (the right-hand side of @ 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.

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:

  1. Precedence: Binding tighter than | (Union) and & (proposed Intersection) ensures expressions like int @Field() | str parse correctly unparenthesized. This excludes operators like |, ^, and &.
  2. Ecosystem Compatibility: New keywords require ecosystem-wide parser migrations. Because Python’s type system is evolving into an arithmetic of types (e.g., replacing Union with |), reusing an operator extends this pattern without unnecessary churn.
  3. 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' → use isinstance(ann, types.AnnotatedType) or typing.get_origin(ann) is Annotated
  • typing._AnnotatedAlias(origin, metadata) → use Annotated[origin, *metadata] or origin @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:

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 via Format.STRING and Format.VALUE. They are incompatible with Format.TYPE, which enforces typing semantics.
  • Structural Evaluation: Because Format.TYPE handles the @ operator structurally, it can evaluate constructs like None @Metadata even though the global NoneType does 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