Annotated Types and Metadata with PEP 593

Annotated[T, *metadata] lets you staple arbitrary objects onto a type. Static checkers strip the extras and see only T, while frameworks read the metadata at runtime through get_type_hints(..., include_extras=True).

This split is the whole point of PEP 593. The type checker treats Annotated[int, Gt(0)] as plain int, so nothing about your inference or assignability changes. But the extra objects survive into the runtime annotation, where a validation library, a dependency injector, or a serializer can pick them up. That is how Pydantic v2, FastAPI, msgspec, and typer attach constraints, dependencies, and CLI options to parameters without inventing a parallel annotation syntax. This page, part of Advanced Typing Patterns & Generics, explains the two-audience model, how to read the extras, and how Annotated differs from NewType.

What the checker sees vs what the runtime sees An Annotated[int, Gt(0)] annotation splits into two views: the static type checker discards the metadata and sees plain int, while the runtime keeps both the type and the Gt(0) metadata object. Annotated[int, Gt(0)] one annotation, two readers Static checker sees int metadata discarded assignability unchanged Runtime sees int + Gt(0) read via get_type_hints include_extras=True
One annotation, two audiences: the checker keeps the type and drops the metadata; the runtime keeps both.

Canonical syntax

Annotated takes a real type as its first argument and one or more metadata objects after it. The metadata can be anything — a string, an instance, a sentinel — the typing system never inspects it.

# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from typing import Annotated, get_type_hints, get_args

class Gt:
    def __init__(self, bound: int) -> None:
        self.bound = bound

PositiveInt = Annotated[int, Gt(0)]

def set_retry_limit(limit: PositiveInt) -> None:
    reveal_type(limit)   # Revealed type is "builtins.int"
    ...

On Python 3.9+ import Annotated from typing; on 3.8 use typing_extensions. At least one metadata argument is required — Annotated[int] is a TypeError. The first argument must be a valid type; the rest are opaque payload.

The checker only sees the first argument

Every static analyzer collapses Annotated[T, ...] to T before doing any work. Assignability, inference, and error codes behave exactly as if you had written T directly.

# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from typing import Annotated

UserId = Annotated[int, "primary key"]

def load_user(user_id: UserId) -> None: ...

load_user(42)        # ok — UserId is just int to the checker
load_user("42")      # mypy: [arg-type]; pyright: reportArgumentType

raw: int = 42
other: UserId = raw  # ok — no distinct type, so no error

That last line is the crucial contrast with NewType, which would reject a raw int. Annotated never creates a distinct type — it decorates an existing one. If you need the checker to keep two same-shaped values apart, reach for NewType; if you need runtime-readable metadata, reach for Annotated. The Annotated vs NewType for domain types page walks through combining both.

Reading the metadata at runtime

The extras are recoverable two ways. get_type_hints(obj, include_extras=True) returns the full Annotated form for each hint; without that flag it strips the metadata just like the checker does. get_args then splits an Annotated object into (T, *metadata).

# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from typing import Annotated, get_type_hints, get_args

class Gt:
    def __init__(self, bound: int) -> None:
        self.bound = bound

def configure(retries: Annotated[int, Gt(0)]) -> None: ...

hints = get_type_hints(configure, include_extras=True)
base, *extras = get_args(hints["retries"])
print(base)                     # <class 'int'>
print(extras[0].bound)          # 0

plain = get_type_hints(configure)          # include_extras defaults to False
print(plain["retries"])         # <class 'int'> — metadata already gone

Forgetting include_extras=True is the single most common Annotated bug: your validation objects silently vanish and the consumer sees a bare type. See using Annotated for validation metadata for a reusable constraint alias and a framework-style consumer.

Runtime vs static analysis Type checkers never execute or validate the metadata in Annotated[int, Gt(0)] — to mypy and pyright the parameter is exactly int, so a value of -5 type-checks cleanly. Enforcing Gt(0) is entirely the runtime consumer's job; if no framework reads the extras, the constraint does nothing at all.

Who reads the extras

The pattern only pays off because tools agreed on it. Pydantic v2 reads constraint objects like annotated_types.Gt and Field(...) from the extras to build validators. FastAPI reads Depends, Query, Path, and Header markers to wire request parsing. msgspec and typer read their own metadata classes the same way. Because the checker ignores all of it, one alias such as PositiveInt = Annotated[int, Gt(0)] carries both the static type and the runtime rule with no duplication — you annotate once and every layer gets what it needs.

Stacking is allowed and order is preserved: Annotated[int, Gt(0), Field(description="retries")] hands both objects to whatever iterates the extras. Nested Annotated flattens, so Annotated[Annotated[int, A], B] is equivalent to Annotated[int, A, B].

Common mistakes

  • Omitting include_extras=True. get_type_hints(fn) drops your metadata and the consumer sees plain T. No error code fires — the bug is silent. Always pass include_extras=True when you need the extras.
  • Expecting the checker to enforce metadata. Annotated[int, Gt(0)] accepting -1 is not a checker failure; mypy/pyright deliberately ignore extras. Validation belongs to the runtime consumer.
  • Writing Annotated[int] with no metadata. This raises TypeError at runtime and mypy flags it with [valid-type]. Annotated needs at least one extra argument.
  • Reaching for Annotated to make a distinct type. It does not — other: UserId = raw_int type-checks. Use NewType when you want mypy to reject the raw value with [arg-type].

FAQ

Does Annotated change how mypy or pyright type-check my code? No. Both analyzers discard everything after the first argument, so Annotated[T, ...] is indistinguishable from T for assignability, inference, and error reporting. The metadata is purely a runtime payload.

How is Annotated different from a plain type alias? A type alias just renames a type; it carries no extra data. Annotated renames and attaches objects that survive into the runtime annotation, where frameworks can read them with get_type_hints(..., include_extras=True).

Can I use Annotated with generics and TypeVar? Yes. The first argument can be any type expression, including a generic parameterised with a TypeVar, e.g. Annotated[list[T], MaxLen(10)]. The metadata rides along and the checker still sees list[T].

Which libraries actually read the metadata? Pydantic v2, FastAPI (Depends, Query, Path), msgspec, and typer are the common ones. Each defines its own marker classes and iterates the extras returned by get_args on the resolved hint.

Back to Advanced Typing Patterns & Generics