Python Annotated: Add Type Metadata

Published on: August 29, 2026
Reading time: 6 minutes
High-angle view of woman coding on a laptop, with a Python book nearby. Ideal for programming and tech content.

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 = 500

Python 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 / time

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

Share:

Facebook
WhatsApp
Twitter
LinkedIn

Article content

    Related articles

    Detailed view of programming code in a dark theme on a computer screen.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python NewType: Keep IDs Distinct

    Learn Python NewType to keep IDs, codes, units, and primitive values distinct, validate boundaries, and prevent domain mix-ups.

    Ler mais

    Tempo de leitura: 5 minutos
    29/08/2026
    Close view of a python resting on sandy terrain outdoors in natural habitat.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python Never: Mark Unreachable Code

    Learn Python Never for non-returning functions, unreachable code, and exhaustive unions with assert_never, Literal, Enum, and match.

    Ler mais

    Tempo de leitura: 5 minutos
    29/08/2026
    Detailed shot of a Jungle Carpet Python (Morelia spilota cheynei) in its natural habitat.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python Concatenate: Change Parameters

    Learn Python Concatenate to add or hide leading parameters in typed decorators with ParamSpec, contexts, locks, and dependencies.

    Ler mais

    Tempo de leitura: 4 minutos
    28/08/2026
    A close-up view of a person's hand signing a business contract on a desk with a pen.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python ParamSpec: Preserve Signatures

    Learn Python ParamSpec to preserve complete function signatures in decorators, callbacks, async wrappers, and higher-order utilities.

    Ler mais

    Tempo de leitura: 4 minutos
    28/08/2026
    Close-up of hands typing on a laptop keyboard, Python book in sight, coding in progress.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python TypeIs: Narrow Both Branches

    Learn Python TypeIs to narrow true and false branches, compare it with TypeGuard, and build sound reusable type predicates.

    Ler mais

    Tempo de leitura: 5 minutos
    28/08/2026
    Hands typing code on a laptop in a workspace. Indoor setting focused on software development.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python TypeGuard: Refine Types Safely

    Learn Python TypeGuard to narrow types and validate collections, TypedDict, Protocol, and external data with runtime checks and static safety.

    Ler mais

    Tempo de leitura: 5 minutos
    28/08/2026