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

    Code and XML structure for canonicalization with Python
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    ElementTree canonicalize: Normalize XML in Python

    Learn Python ElementTree canonicalize for stable XML comparisons, hashes, namespaces, comments, and signature preparation.

    Ler mais

    Tempo de leitura: 5 minutos
    22/10/2026
    Python programming code displayed on a screen
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    T-strings and templatelib: Structured Interpolation

    Learn Python t-strings and string.templatelib for structured interpolation, safer renderers, logging, and custom processing.

    Ler mais

    Tempo de leitura: 5 minutos
    22/10/2026
    SQLite database and Python code for selective iterdump export
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    SQLite iterdump: Create Selective Dumps in Python

    Learn sqlite3.Connection.iterdump filters for selective SQLite dumps, dependency checks, validation, and safer restoration.

    Ler mais

    Tempo de leitura: 4 minutos
    21/10/2026
    Code and files representing Python data compression
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    compression.zstd: Fast Zstandard in Python

    Learn Python compression.zstd for fast file and data compression, streaming, integrity checks, and safe decompression.

    Ler mais

    Tempo de leitura: 7 minutos
    21/10/2026
    Configuration file and code structure for Python ConfigParser
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    ConfigParser: Read INI Files Without Sections

    Learn Python ConfigParser unnamed sections with validation, typed values, compatibility, layered settings, and safe INI writing.

    Ler mais

    Tempo de leitura: 5 minutos
    20/10/2026
    Binary data and computer memory representing Python memoryview
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    memoryview count and index: Search Without Copies

    Learn Python memoryview count and index to search values in zero-copy buffers and validate binary data efficiently.

    Ler mais

    Tempo de leitura: 5 minutos
    20/10/2026