Annotations at runtime

typing · Expert · 12 min

Frameworks like Pydantic, FastAPI and dataclasses read annotations at runtime; their quirks affect your code.

Annotations are stored on the object: func.__annotations__, Class.__annotations__. Reading them is how runtime tools work, and there are subtleties.

Forward references: a hint naming a class defined later must be a string, def f() -> "Node", otherwise it raises NameError when the function is defined. from __future__ import annotations (PEP 563) turns all annotations into strings automatically.

Strings must be evaluated to be useful at runtime. typing.get_type_hints(obj) does this: it evaluates string annotations in the right namespace, resolves forward references and, with include_extras=True, keeps Annotated metadata. Prefer it over raw __annotations__.

Introspection helpers: get_origin(list[int]) gives list, get_args(dict[str, int]) gives (str, int).

`Annotated[T, meta]` attaches arbitrary metadata to a type, ignored by checkers but used by libraries: Annotated[int, Field(gt=0)] in Pydantic, Annotated[str, Query()] in FastAPI.

Python 3.14 changes the model (PEP 649/749): annotations are evaluated lazily on first access, forward references work without quotes, and the new annotationlib module offers formats for reading them. On 3.13, write string or __future__ annotations and read with get_type_hints.

Example

from typing import Annotated, get_args, get_origin, get_type_hints

class Node:
    def __init__(self, value: int, parent: "Node | None" = None) -> None:
        self.value, self.parent = value, parent

    def root(self) -> "Node":
        return self if self.parent is None else self.parent.root()

Positive = Annotated[int, "must be > 0"]
def scale(x: Positive, factor: float = 1.0) -> float: return x * factor

print(Node.__init__.__annotations__["parent"])   # Node | None  (still a string)
print(get_type_hints(Node.__init__)["parent"])   # __main__.Node | None
print(get_type_hints(scale))
# {'x': <class 'int'>, 'factor': <class 'float'>, 'return': <class 'float'>}
hint = get_type_hints(scale, include_extras=True)["x"]
print(get_origin(hint) is Annotated, get_args(hint))
# True (<class 'int'>, 'must be > 0')
print(get_origin(dict[str, int]), get_args(dict[str, int]))
# <class 'dict'> (<class 'str'>, <class 'int'>)

Remember

  • Forward references are strings until evaluated
  • get_type_hints() evaluates and resolves annotations; prefer it over __annotations__
  • Annotated[T, meta] carries metadata that runtime libraries read

Watch out for

  • Using from __future__ import annotations with a library that reads raw __annotations__ and expects classes, not strings.

Check yourself

  1. Why prefer typing.get_type_hints(f) over f.__annotations__ in a runtime tool?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/typing.html#typing.get_type_hints

Builds on

Unlocks

Nothing builds on this yet.