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.
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.
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.
# 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).
# 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.
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].
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.
- Omitting
include_extras=True.get_type_hints(fn)drops your metadata and the consumer sees plainT. No error code fires — the bug is silent. Always passinclude_extras=Truewhen you need the extras. - Expecting the checker to enforce metadata.
Annotated[int, Gt(0)]accepting-1is not a checker failure; mypy/pyright deliberately ignore extras. Validation belongs to the runtime consumer. - Writing
Annotated[int]with no metadata. This raisesTypeErrorat runtime and mypy flags it with[valid-type].Annotatedneeds at least one extra argument. - Reaching for
Annotatedto make a distinct type. It does not —other: UserId = raw_inttype-checks. UseNewTypewhen 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.