skip to content

Annotations at Runtime

Annotations as data a running program reads, not just checker input: deferred evaluation in 3.14, resolving a string back to an object, and taking a generic apart. Frameworks live on this machinery.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

12

What is the __annotate__ function that Python 3.14 compiles for annotated objects?

level: middleimportance: must knowfreq 45%

answer

  1. Where did the annotation expressions go?
  2. A hidden attribute holds them
  3. It takes one integer argument
  4. None when nothing is annotated
  5. __annotations__ calls it once and caches

basics

~20 s

It is an implicitly compiled function holding a function's, class's or module's annotation expressions. Python 3.14 attaches it as annotate and calls it with a format argument the first time annotations is read, then caches the result.

solid answer

~40 s

PEP 649 changed the compiler so that annotation expressions are no longer emitted inline in the `def` or `class` statement. Instead they go into a separate implicitly-created function stored as `__annotate__` on the function, class or module; an object with no annotations has `__annotate__` set to `None`. The function takes one argument, an integer format from `annotationlib.Format`, and returns the annotations dict. `__annotations__` is now lazily computed: the first access calls `__annotate__(Format.VALUE)` and caches the dict, so any `NameError` surfaces there rather than at definition time. The generated function is a closure over the scope where the annotations were written, which is why an annotation can name a local of an enclosing function. In practice you call `annotationlib.get_annotations` rather than invoking `__annotate__` yourself.

code

python · 11 lines
python
import annotationlib
from annotationlib import Format

def handle(job: Missing) -> int: ...

print(handle.__annotate__ is not None)
print(annotationlib.get_annotations(handle, format=Format.STRING))
try:
    handle.__annotations__
except NameError as exc:
    print("deferred:", exc)

go deeper

for a junior

Know that in Python 3.14 the annotation expressions live in a separate function and only run when something reads the annotations. You are not expected to call it yourself.

for a middle

Explain the mechanics: one implicit function per annotated object, one integer format argument, None when unannotated, and annotations calling it once with the VALUE format and caching the dict.

for a senior

Be ready to reason about the consequences in a running service: where the NameError now surfaces, that a failed call is not cached, how a traceback through an annotate frame reads, and why introspection code should go through annotationlib.

for a principal

Own the API-stability angle: the dunder is an implementation detail, so libraries in your stack should depend on the public introspection API, and you should know what that means for tooling that must span 3.13 and 3.14.

### The compiler change Before Python 3.14 the bytecode for a `def` statement included the annotation expressions inline: they were evaluated on the spot and the resulting dict was attached to the function object. PEP 649 (with the amendments in PEP 749) moved them out. The compiler now gathers every annotation expression belonging to a function, a class body or a module into one implicitly-created function, and binds it to the attribute `__annotate__`. The attribute exists on all three annotatable kinds — functions, classes and modules — and is `None` when the object carries no annotations at all: ```python def plain(x): return x print(plain.__annotate__) # None ``` ### Its signature and its job The annotate function takes exactly one argument: an integer format, in practice a member of `annotationlib.Format`. It returns a fresh dict mapping names to annotations, built for that format. The formats are `VALUE`, `VALUE_WITH_FAKE_GLOBALS`, `FORWARDREF` and `STRING`; `VALUE` is the plain "evaluate the expressions for real" mode. A hand-written or generated annotate function is only required to support `VALUE`; asked for a format it cannot produce, it raises `NotImplementedError`, and the `annotationlib` machinery falls back to running it with a specially-prepared globals mapping so it can still synthesise the string or forward-reference forms. That fallback is why `VALUE_WITH_FAKE_GLOBALS` exists and why it is an implementation detail you never pass yourself. ### How __annotations__ relates to it `__annotations__` is now a lazily-computed, cached view. The first read calls `__annotate__(Format.VALUE)`, stores the resulting dict on the object, and returns it. Subsequent reads return the cached dict, so mutating it in place is visible to later readers, exactly as before. If the call raises — the usual cause is a name in an annotation that is not bound at read time — nothing is cached, so the next read runs the annotate function again and raises again. That is a meaningful behavioural detail: the failure is repeatable rather than poisoned-once. Assigning to `__annotations__` directly overrides the machinery and clears the annotate function: ```python def h(y: int): ... h.__annotations__ = {"y": str} print(h.__annotate__) # None ``` ### It is a closure, and that matters The generated function closes over the scope in which the annotations were written. So an annotation naming a class defined inside an enclosing function still resolves: ```python def outer(): class Local: ... def inner(v: Local) -> None: ... return inner print(outer().__annotations__) # {'v': <class '__main__.outer.<locals>.Local'>, 'return': None} ``` That case is unresolvable when annotations are stored as strings, because a string carries no scope — one of the concrete reasons PEP 649 was preferred over making PEP 563's stringification the default. Annotations in a class body get similar treatment: the annotate function is given access to the class namespace so names defined earlier in the body resolve. ### What it costs and what it saves Definition time gets cheaper: the interpreter attaches a small code object instead of evaluating potentially expensive subscripted generics. Import-heavy modules whose annotations are never introspected pay essentially nothing. The cost is one extra code object per annotated function, class or module, and a slightly indirect debugging story — a traceback for a bad annotation points at the annotate function, and the frame is named `__annotate__`. Introspection costs the same as before, paid once, at the moment of first access rather than at import. ### Do not call it directly Treat `__annotate__` the way you treat any dunder implementation detail: know it exists, know what it means when you see it in a traceback or a `dir()` listing, and go through the public API instead. `annotationlib.get_annotations(obj, format=...)` handles the format negotiation, the fake-globals fallback, objects whose `__annotate__` is `None`, and modules still using the old string-annotation future import. Calling `obj.__annotate__(1)` yourself skips all of that. ### Interview framing The compact answer is three facts: the compiler moved annotation expressions into a separate `__annotate__` function in 3.14; `__annotations__` calls it lazily on first access with the `VALUE` format and caches the result; and because it is a closure it resolves names the old string-based approach could not.

  • What does __annotate__ hold when the object has no annotations at all?
    `None`. The compiler only creates an annotate function when there is at least one annotation to put in it, so an unannotated function, class or module has `__annotate__` set to `None` and `__annotations__` returns an empty dict. Assigning `__annotations__` directly also resets `__annotate__` to `None`, because the explicit dict supersedes the generated function.
  • Why is the annotate function a closure rather than a plain top-level function?
    Because annotations frequently name things that only exist in the surrounding scope — a class defined inside a factory function, or a name bound earlier in a class body. Closing over that scope lets those resolve at read time. String annotations cannot do it: a string carries no scope, so resolving one later has nothing but module globals to work with. That limitation was a direct argument for PEP 649 over stringification.
  • Should application code ever call __annotate__ directly?
    No. Use `annotationlib.get_annotations`, which negotiates the requested format, handles an annotate function that raises `NotImplementedError` for a format it cannot produce, copes with `__annotate__` being `None`, and understands modules still using the old string-annotation future import. Calling the dunder yourself bypasses all of that and couples you to an implementation detail.

saying these in an interview costs you the question

  • Says __annotate__ takes no arguments and returns a string
  • Thinks __annotations__ is recomputed on every access
  • Claims every object has an __annotate__ function
  • Confuses __annotate__ with a decorator you apply yourself
  • Believes annotations became strings rather than lazy objects
  • Says a failed annotate call is cached and never retried

context

open as a page

What does typing.get_origin return for str | None versus typing.Union[str, None]?

level: middleimportance: must knowfreq 40%

basics

~20 s

On Python 3.14 both return typing.Union, because typing.Union and types.UnionType are now the same object. typing.get_args gives (str, NoneType) for either spelling. On 3.10 through 3.13 the pipe form reported types.UnionType and the bracket form reported typing.Union.

open as a page

Why does typing.get_type_hints raise NameError when a webhook receiver resolves its handler annotations at startup?

level: seniorimportance: must knowfreq 55%

basics

~20 s

The annotation names a class imported only inside an if TYPE_CHECKING block. That constant is False at runtime, so the name never enters the defining module's globals and resolution fails. Import it for real, or pass it in via localns.

open as a page

Since Python 3.14, do you still need quotes around a forward reference in an annotation?

level: juniorimportance: should knowfreq 35%

basics

~10 s

No. Python 3.14 evaluates annotations lazily, so a name used in an annotation only has to exist when something reads the annotations, not when the def or class statement runs. Quotes became optional.

open as a page

Why does typing.get_type_hints(f) return a class where f.__annotations__ holds a string?

level: juniorimportance: should knowfreq 35%

basics

~20 s

A quoted annotation such as "Order" is stored verbatim as a str. typing.get_type_hints evaluates that text as an expression in the function's module globals and returns a new dict whose values are the real type objects.

open as a page

What do typing.get_origin and typing.get_args return for dict[str, int]?

level: juniorimportance: should knowfreq 30%

basics

~20 s

typing.get_origin(dict[str, int]) returns the plain dict class and typing.get_args returns the tuple (str, int). Together they split a subscripted annotation into its container and its type arguments. For an unsubscripted class such as int, get_origin returns None.

open as a page

Why does typing.get_type_hints strip Annotated metadata by default?

level: middleimportance: should knowfreq 30%

basics

~20 s

Its default contract is to hand back plain types, so Annotated[X, extras] is reduced to X for callers that predate Annotated and would choke on it. Pass include_extras=True to get the Annotated form back untouched.

open as a page

Why does isinstance(value, list[int]) raise TypeError, and what check works instead?

level: middleimportance: should knowfreq 34%

basics

~10 s

list[int] is a parameterized generic alias, and isinstance rejects it: the interpreter cannot check element types without walking the whole value. Reduce the alias with typing.get_origin first, then check elements yourself using typing.get_args.

open as a page

Your worker uses from __future__ import annotations and reads annotations at startup on Python 3.14 — should that import stay?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Drop it once your minimum version is 3.14. The future import stringifies every annotation permanently, so runtime consumers must re-resolve text; 3.14's deferred evaluation gives the same freedom from definition-time errors while handing back real objects.

open as a page

How does typing.get_type_hints resolve a class's hints differently from a function's?

level: seniorimportance: should knowfreq 40%

basics

~20 s

For a class, typing.get_type_hints walks the MRO from the base end, merging each class's own annotations and evaluating each against its defining module's globals. For a function it evaluates one annotation set against the function's globals.

open as a page

A payroll CSV importer dispatches on typing.get_origin; why do Annotated columns fall through?

level: seniorimportance: should knowfreq 26%

basics

~10 s

Because typing.get_origin(Annotated[list[Decimal], meta]) returns typing.Annotated, not list. The metadata wrapper is the outermost alias, so the importer's list and dict branches never match. Strip it first: typing.get_args returns the wrapped type at index 0.

open as a page

What do annotationlib.Format's VALUE, FORWARDREF and STRING each return?

level: middleimportance: nice to knowfreq 22%

basics

~20 s

VALUE evaluates the annotation expressions for real and raises NameError on an unresolvable name. FORWARDREF evaluates what it can and substitutes an annotationlib.ForwardRef for the rest. STRING returns the annotation source text without evaluating anything.

open as a page