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.