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