Type annotations usually describe which values a function accepts or returns. Real applications also need constraints, units, formats, documentation, validation rules, and information consumed by frameworks. typing.Annotated attaches metadata to a type without changing its basic identity for checkers that do not understand that metadata.
This guide covers enriched types, runtime inspection, dataclasses, APIs, validation, units, NewType, framework integration, and the limits of metadata-driven design.
Why plain types are sometimes not enough
def create_user(name: str, age: int) -> None:
...The signature says that age is an integer, but it does not say that the value should be between 0 and 130. That rule may live in documentation, a separate schema, or distant validation code.
Annotated keeps related information close to the type:
from typing import Annotated
Age = Annotated[int, "0..130"]
def create_user(name: str, age: Age) -> None:
...A checker that ignores metadata still treats Age as int. A specialized tool may interpret the string and apply a rule.
Basic syntax
Annotated[BaseType, metadata1, metadata2, ...]The first argument is the actual type. The remaining values may be strings, objects, enums, dataclasses, or any other values understood by the consumer.
from dataclasses import dataclass
from typing import Annotated
@dataclass(frozen=True)
class Range:
minimum: int
maximum: int
Percentage = Annotated[int, Range(0, 100)]Structured objects are usually safer than free-form strings because they reduce spelling mistakes and simplify inspection.
Annotated does not validate by itself
value: Percentage = 500Python does not automatically execute Range. The annotation only carries metadata. A framework, decorator, or validation function must inspect and enforce it.
This distinction is essential: Annotated does not replace runtime checks and does not turn an int into a validated class.
How type checkers treat Annotated
Tools that do not understand the metadata should treat Annotated[int, ...] as int. This keeps annotations compatible and allows independent libraries to add their own metadata without changing core type relationships.
def double(value: int) -> int:
return value * 2
percentage: Percentage = 20
result = double(percentage)Reading metadata with get_type_hints
from typing import get_type_hints
def configure(timeout: Annotated[int, Range(1, 60)]) -> None:
...
hints = get_type_hints(configure, include_extras=True)
print(hints["timeout"])include_extras=True preserves Annotated and related extras. Without it, the result generally contains only the base type.
Inspecting origin and arguments
from typing import get_args, get_origin, Annotated
annotation = get_type_hints(
configure,
include_extras=True,
)["timeout"]
print(get_origin(annotation))
print(get_args(annotation))get_args() returns the base type followed by metadata. Avoid depending on private implementation attributes in the typing module.
A small validator
from typing import get_args, get_origin, get_type_hints
def validate_call(function, arguments: dict[str, object]) -> None:
hints = get_type_hints(function, include_extras=True)
for name, value in arguments.items():
annotation = hints.get(name)
if get_origin(annotation) is Annotated:
base_type, *metadata = get_args(annotation)
if not isinstance(value, base_type):
raise TypeError(f"{name} must be {base_type}")
for item in metadata:
if isinstance(item, Range):
if not item.minimum <= value <= item.maximum:
raise ValueError(f"{name} is outside the range")This is educational code. Production validators must handle unions, generics, forward references, subclasses, bool versus int, nested structures, and detailed error reporting.
Multiple metadata objects
@dataclass(frozen=True)
class Description:
text: str
@dataclass(frozen=True)
class Unit:
name: str
Temperature = Annotated[
float,
Unit("celsius"),
Range(-273, 1000),
Description("Temperature measured by the sensor"),
]Different tools may consume different parts. A documentation generator reads Description, a validator uses Range, and a presentation layer interprets Unit.
Metadata order
Order is preserved and may matter to the consuming library. Do not rely on undocumented ordering rules. When metadata does not represent a processing pipeline, search by object type instead of position.
Nested Annotated
Base = Annotated[int, "base"]
Special = Annotated[Base, "special"]Typing tools may flatten nested Annotated metadata according to defined rules. Public APIs should prefer a single clearly composed annotation and test inspection behavior across supported Python versions.
Reusable aliases
PositiveId = Annotated[int, Range(1, 2_147_483_647)]
ShortName = Annotated[str, "1..80 characters"]Aliases reduce repetition and centralize conventions. Changing a shared alias affects many APIs, so treat it as part of the public contract.
Annotated and NewType
NewType creates a static distinction; Annotated adds metadata. They solve different problems and can be combined.
from typing import NewType
UserId = NewType("UserId", int)
ValidatedUserId = Annotated[UserId, Range(1, 2_147_483_647)]The checker keeps UserId's identity while a runtime tool can enforce the range. See the guide to Python NewType.
Annotated and Literal
from typing import Literal
Format = Annotated[
Literal["json", "csv"],
Description("Export format"),
]Literal restricts exact values statically; Annotated adds documentation or tool-specific behavior.
Annotated in dataclasses
from dataclasses import dataclass
@dataclass
class Product:
name: Annotated[str, Description("Public product name")]
price: Annotated[float, Range(0, 1_000_000)]The dataclass does not enforce metadata automatically. A library can inspect type hints and build validation or schemas.
Annotated in web APIs
Frameworks may use metadata to describe parameter sources, limits, examples, and documentation while preserving a normal type for static analysis.
# Conceptual example; Query belongs to a framework
Limit = Annotated[int, Query(minimum=1, maximum=100)]Follow the framework's official documentation. Metadata objects are not universally understood by every library.
Metadata as a library protocol
The objects placed in Annotated form a small language between application code and a consumer. Define accepted object classes, whether duplicates are allowed, how conflicts are resolved, and whether order changes behavior.
Units of measure
Meters = Annotated[float, Unit("m")]
Seconds = Annotated[float, Unit("s")]
def speed(
distance: Meters,
time: Seconds,
) -> Annotated[float, Unit("m/s")]:
return distance / timeTo the checker, all values are still floats and can be mixed. Use NewType or value classes when preventing that mix is important. Annotated provides metadata, not nominal distinction.
Security and untrusted data
Do not treat Annotated as a security boundary. Callers can ignore annotations, and Python does not enforce them automatically. Validate permissions, ranges, and formats at runtime where data enters the system.
Forward references
get_type_hints() can resolve future references using module namespaces. Plugin systems, local classes, and conditional imports may require explicit globalns and localns. Annotation evaluation can execute resolution logic, so do not treat untrusted code metadata as inert data.
Preserving metadata through decorators
Decorators may replace or copy annotations. Use functools.wraps and avoid overwriting __annotations__ unnecessarily. ParamSpec preserves parameters statically, while Annotated metadata must remain available to runtime consumers.
Metadata serialization
Arbitrary objects inside Annotated are not automatically JSON serializable. When metadata must be sent to documentation services, caches, or remote tools, prefer simple immutable dataclasses, enums, and explicit conversion.
Version compatibility
Annotated is available in modern versions of typing. Older versions can use typing_extensions.Annotated. Confirm that the consuming library supports include_extras=True and the Python versions in your support matrix.
Annotated versus docstrings
Docstrings are better for long explanations and overall behavior. Annotated is better for structured metadata tied to one parameter or return value. Avoid large ambiguous strings when a small metadata class can express the rule more clearly.
Annotated versus a value class
A value class can validate in its constructor, provide methods, and exist distinctly at runtime. Annotated keeps the original value and depends on an external consumer. Choose classes for strong invariants and behavior; choose Annotated for integration and description.
Common mistakes
- Expecting automatic validation: Annotated only transports metadata.
- Using unstructured strings everywhere: spelling mistakes become hard to detect.
- Forgetting include_extras=True: inspection loses metadata.
- Trusting metadata as security: ordinary calls can bypass it.
- Using it for nominal distinction: two annotated ints remain ints to the checker.
- Coupling the domain to one framework: central types become hard to reuse.
Complete validated configuration example
from dataclasses import dataclass
from typing import Annotated, get_args, get_origin, get_type_hints
@dataclass(frozen=True)
class Minimum:
value: float
@dataclass(frozen=True)
class Maximum:
value: float
Port = Annotated[int, Minimum(1), Maximum(65535)]
Timeout = Annotated[float, Minimum(0.1), Maximum(120.0)]
@dataclass
class Configuration:
port: Port
timeout: Timeout
def validate_dataclass(instance: object) -> None:
hints = get_type_hints(type(instance), include_extras=True)
for name, annotation in hints.items():
value = getattr(instance, name)
if get_origin(annotation) is not Annotated:
continue
base_type, *metadata = get_args(annotation)
if not isinstance(value, base_type):
raise TypeError(f"{name}: invalid type")
for item in metadata:
if isinstance(item, Minimum) and value < item.value:
raise ValueError(f"{name}: below minimum")
if isinstance(item, Maximum) and value > item.value:
raise ValueError(f"{name}: above maximum")
config = Configuration(port=8000, timeout=5.0)
validate_dataclass(config)The example keeps simple runtime values, centralizes metadata, and applies an explicit policy. Mature validators must also handle inheritance, optional fields, collections, and multiple error reports.
Best practices
Use named immutable metadata objects. Document which tool consumes each one. Keep critical rules in runtime code as well. Test inspection across supported Python versions. Avoid spreading framework-specific objects through the domain when interface-layer aliases are sufficient.
Conclusion
typing.Annotated carries metadata alongside types without changing their basic static behavior. It is useful for validation, schemas, documentation, units, serialization, and framework integration.
The official Python Annotated documentation defines its semantics. Treat metadata as an explicit protocol between code and a consumer, and choose NewType or value classes when you need real distinction or enforced invariants.







