Python typing.Annotated: Attach Metadata to Types

Python's typing.Annotated provides a standardized mechanism for attaching arbitrary, non-type metadata to variable declarations and type hints without altering how static type checkers interpret them. Introduced in Python 3.9 via PEP 593, it bridges the gap between static analysis and runtime tooling. This article explains the role of typing.Annotated, how it functions with static type checkers, how runtime frameworks leverage its metadata, and how to inspect these annotations programmatically.

The Purpose of typing.Annotated

Before typing.Annotated, developers and library authors often struggled to attach runtime metadata to type hints. Common workarounds included subclassing types, using custom wrapper classes, or defining non-standard docstrings. These approaches frequently broke static type checking with tools like Mypy and Pyright or caused runtime overhead.

typing.Annotated solves this by separating the core type from its contextual metadata. Its signature accepts a base type as the first argument, followed by one or more arbitrary metadata objects:

from typing import Annotated

# Syntax: Annotated[BaseType, Metadata1, Metadata2, ...]
Age = Annotated[int, "Value must be between 0 and 120"]

Static Type Checker Behavior

To static analysis tools (Mypy, Pyright, IDEs), Annotated[T, ...] is treated identically to T. The metadata elements are completely invisible to static type inference and verification rules.

For example:

from typing import Annotated

PositiveInt = Annotated[int, "greater than zero"]

count: PositiveInt = 10
count = "ten"  # Static type checker raises: Incompatible types (expression has type "str", variable has type "int")

The type checker enforces that count must be an integer, ignoring the string "greater than zero". This guarantees that attaching operational metadata never degrades or complicates static typing guarantees.

Accessing Metadata at Runtime

The metadata stored in Annotated is preserved at runtime in the type's __metadata__ attribute. Standard inspection tools like typing.get_type_hints() can extract this information when configured with the include_extras=True flag:

from typing import Annotated, get_type_hints

class UserProfile:
    username: Annotated[str, {"max_length": 30, "unique": True}]
    score: Annotated[int, "Must be positive"]

hints = get_type_hints(UserProfile, include_extras=True)

print(hints["username"].__metadata__)
# Output: ({'max_length': 30, 'unique': True},)

print(hints["score"].__metadata__)
# Output: ('Must be positive',)

By default, calling get_type_hints(UserProfile) without include_extras=True strips the Annotated wrapper and returns the raw base types (str and int), ensuring backward compatibility with tools that do not expect metadata wrappers.

Primary Use Cases

Modern Python frameworks rely heavily on Annotated for declarative design patterns:

  1. Validation and Constraints: Libraries like Pydantic use Annotated alongside constraint specifiers (such as Field()) to define boundaries, regex patterns, or serialization aliases directly within data models.
  2. Dependency Injection: Web frameworks like FastAPI use Annotated to inject dependencies (such as database sessions, headers, or query parameters) into route functions via Annotated[Session, Depends(get_db)].
  3. Database ORM Mapping: Modern ORMs use metadata to declare database column attributes, foreign keys, or indexing parameters alongside standard Python types.

By providing a clean, standard interface for attaching supplementary information to types, typing.Annotated allows frameworks to build rich runtime behaviors while maintaining strict, fully compatible static type checking.