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.

Anatomy of an Annotated form Annotated of int with Gt(0) and a description splits into a first slot that must be a valid type and following slots that are opaque metadata the type system never inspects. int slot 0 — the type Gt(0) slot 1 — metadata "retry limit" slot 2 — metadata must be a valid type checker reads this any objects, order preserved runtime reads these; checker ignores them
Slot 0 is the type the checker uses; every later slot is opaque metadata for the runtime.
# 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.

A resolved Annotated object exposes two useful attributes. PositiveInt.__origin__ is the underlying type (int) and PositiveInt.__metadata__ is the tuple of extras ((Gt(0),)) — this is the low-level pair that get_args reconstructs as (int, Gt(0)). The order of the extras is significant and preserved verbatim, so a consumer that iterates __metadata__ sees Gt(0) before a later Le(100). Nested forms flatten: Annotated[Annotated[int, A], B] normalises to Annotated[int, A, B] at construction time, so you never have to unwrap layers of Annotated. The metadata objects are stored by reference and are not required to be hashable unless you put the whole alias in a set or use it as a dict key; for that reason libraries like annotated_types make their markers frozen dataclasses, which are hashable and comparable. One gotcha with from __future__ import annotations: the annotation becomes the string "PositiveInt" in __annotations__, and only get_type_hints(..., include_extras=True) evaluates that string back into the real Annotated object with its metadata intact.

Version-wise, Annotated shipped in typing with Python 3.9 as part of PEP 593, and in typing_extensions for 3.7–3.8; the include_extras flag on get_type_hints arrived at the same time. Equality follows the underlying pieces, so Annotated[int, Gt(0)] == Annotated[int, Gt(0)] is True when the markers compare equal — another reason frozen-dataclass markers are convenient. The form also composes with the PEP 695 type statement (type PositiveInt = Annotated[int, Gt(0)] on 3.12+) and with generic aliases such as Annotated[list[T], MaxLen(10)]; mypy and pyright treat every one of these spellings identically to the bare underlying type.

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.

Reduction to the first argument Annotated of int with two metadata objects reduces to plain int, and both mypy and pyright analyse that int identically. Annotated[int, "pk", Gt(0)] source annotation reduce int mypy analyses int pyright analyses int
Both checkers strip the metadata first, so every analysis runs against plain int.
# 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.

Because the reduction happens before analysis, reveal_type never shows you the wrapper: mypy prints Revealed type is "builtins.int" and pyright prints int, not Annotated[int, ...]. The collapse applies uniformly wherever an annotation can legally appear — parameters, return types, bare variable annotations, class and instance attributes, TypedDict fields, and dataclass fields — so none of those positions gain or lose type safety by being wrapped. What the checker does still validate is slot 0: it must be a legal type expression. Annotated["not a type", x] is reported by mypy as [valid-type] and by pyright as reportInvalidTypeForm, exactly as a malformed annotation would be without the wrapper. The metadata slots, by contrast, are never type-checked at all — you can put a syntactically nonsensical object there and no diagnostic fires, because to the checker those positions effectively do not exist. Assignment is symmetric for the same reason: both x: UserId = some_int and y: int = some_user_id type-check, since there is only ever one type (int) in play.

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

From stored annotation to base plus extras The pipeline runs from the raw annotation string, through get_type_hints with include_extras true, to the resolved Annotated form, and finally get_args splits it into the base type and metadata. __annotations__ "Annotated[int,Gt(0)]" get_type_hints include_extras=True Annotated form int + Gt(0) get_args (int, Gt(0)) drop include_extras and the pipeline yields just int — the metadata is gone
Resolve the hint with include_extras, then split it with get_args to reach the base type and its 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.

A subtle asymmetry catches people: get_origin and the __origin__ attribute disagree for Annotated. get_origin(Annotated[int, Gt(0)]) returns the special form typing.Annotated, whereas the object’s own __origin__ attribute is the underlying int. If you are dispatching on the shape of a hint, test get_origin(hint) is Annotated first, then use get_args to split off the base type and extras.

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

X = Annotated[int, Gt(0)]
get_origin(X)          # typing.Annotated  (NOT int)
X.__origin__           # <class 'int'>
get_args(X)            # (int, Gt(0))
X.__metadata__         # (Gt(0),)

get_type_hints does more than fetch __annotations__: it evaluates string annotations (so it works under from __future__ import annotations, where every annotation is stored as a raw string), resolves forward references, and merges annotations inherited from base classes — with include_extras=True preserving the Annotated wrappers throughout. Reading obj.__annotations__[name].__metadata__ works only when the annotation is a live object; the moment from __future__ import annotations turns it into a string, that attribute access raises AttributeError, which is exactly why frameworks always route through get_type_hints. On Python 3.8, import both Annotated and get_type_hints from typing_extensions so include_extras is available; it moved into the stdlib typing.get_type_hints in 3.9.

Two metadata concepts are easy to conflate. Annotated’s __metadata__ tuple is unrelated to dataclasses.field(metadata=...): the former lives on the type and is read by validators, the latter lives on the Field object and is read by dataclasses.fields(). A dataclass attribute can carry both — x: Annotated[int, Gt(0)] = field(metadata={"units": "ms"}) — and each is retrieved through its own API. Finally, resolving hints is not free: get_type_hints re-evaluates annotations on every call, so long-lived frameworks resolve once and cache the split (base, extras) per function or model rather than paying the cost at each validation.

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.

Which library reads which markers Four libraries each read their own marker classes from the same Annotated metadata: Pydantic reads Field and constraints, FastAPI reads Depends and Query, msgspec reads Meta, and typer reads Option and Argument. library markers it reads from __metadata__ Pydantic v2 Field, Gt/Le, BeforeValidator, PlainSerializer FastAPI Depends, Query, Path, Header, Body, Cookie msgspec msgspec.Meta typer Option, Argument
Each tool isinstance-checks for its own markers and ignores the rest, so many libraries share one annotation.

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

The convention that makes this composable is simple: a consumer iterates __metadata__, uses isinstance to pick out the marker classes it understands, and ignores everything else. Because unknown extras are skipped rather than rejected, several libraries can coexist on one annotation — Annotated[int, Gt(0), Field(description="retries"), SomeOtherMarker()] hands each tool only the markers it recognises. annotated_types exists precisely to give the ecosystem a common constraint vocabulary (Gt, Ge, Lt, Le, Len, MultipleOf, Interval, Predicate) so that Pydantic v2, msgspec, and cattrs interpret the same objects the same way. Pydantic v2 layers its own Field, BeforeValidator, AfterValidator, WrapValidator, and PlainSerializer markers on top; FastAPI adds Depends, Query, Path, Header, Body, and Cookie. In every case the marker is an ordinary instance living in __metadata__, and the checker — seeing only slot 0 — is entirely unaware any of it happened.

Common mistakes

Most Annotated confusion comes from reaching for it when a different tool fits the job — the choice between a distinct type, a runtime rule, and a plain nickname decides which construct you actually want.

Choosing between NewType, Annotated, and a plain alias Starting from the question of what you need, the tree branches to NewType for a distinct type, Annotated for a runtime rule, and a plain type alias for just a shorter name. What do you actually need? pick one a distinct type checker keeps it separate NewType a runtime rule a framework enforces it Annotated just a shorter name no extra data or safety type alias
Distinct type means NewType; runtime rule means Annotated; a bare nickname is a plain alias.
  • 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