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].

Of the 10,000 most downloaded PyPI projects, 43.4% transitively depend on Annotated. Migrating the FastAPI codebase to the new shorthand removed 2,524 LOCs.

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.

Because NoneType lacks __matmul__, expressions like None @ Metadata will raise a TypeError at runtime.

To support this safely at runtime, library authors are encouraged to implement __rmatmul__ on their metadata objects. This provides a robust fallback for types that do not invoke __matmul__ on the left operand (such as NoneType or ForwardRef).

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.

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

Analyzing the top 10,000 PyPI projects revealed 0 metaclass overloads for __matmul__ or __rmatmul__. (For comparison, the analysis caught rare __or__ and __and__ overloads in libraries like pgpy (#1,690), dataclass-wizard (#2,205), and multimethod (#2,377)).

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.

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

Ecosystem Migration

We validated this shorthand by migrating several major type-directed frameworks. To ensure tests continued to pass, we used a two-phase approach:

  1. Add Support: We first updated the internal machinery of the libraries to accept the @ syntax.
  2. Move the Project: We then migrated every internal usage of Annotated to the @ operator.

The required modifications to add support were:

  • FastAPI: 0 LOCs (inherited from Pydantic)
  • Pydantic: 50 LOCs
  • SQLAlchemy: 282 LOCs
  • cattrs: 155 LOCs
  • Hypothesis: 15 LOCs
  • Beartype: 12 LOCs

Most of these changes were isolated to test suites and string representation logic (like __repr__) to maintain backward compatibility. The totals also include adding a two-line __rmatmul__ method to the libraries’ base metadata classes, which acts as a fallback to support unresolvable forward references and None @ Metadata.

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.

Sole Reliance on __rmatmul__

We explicitly reject relying solely 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?).

Structural Evaluation Format (Format.TYPE)

Early drafts proposed a new Format.TYPE in annotationlib (PEP 749) to structurally evaluate the @ operator and preserve metadata on unresolvable ForwardRef instances.

This was rejected. Prototype integrations (beartype, pydantic, fastapi, sqlalchemy, and hypothesis) showed the existing ecosystem handles the @ operator using annotationlib’s current tools.

No-Space Formatting (@annot)

We initially considered formatting the shorthand without a space (e.g., int @Field) to visually mirror function decorators. This was rejected because it forces formatters (like Black and Ruff) to maintain complex, context-dependent rules for the @ operator.

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?

Acknowledgements

Thanks to Hugo van Kemenade, Jelle Zijlstra, and Eric Traut for their feedback, guidance, and assistance in refining this proposal.

References