Why does typing.get_type_hints(f) return a class where f.__annotations__ holds a string?
answer
- Stored text versus a real object
- Something has to evaluate the quoted name
- Which namespace the expression is evaluated in
- Defaults to the defining module's globals
- You get back a fresh dict
basics
~20 sA 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.
solid answer
~40 sAnnotations are stored, not resolved. Writing `def handle(order: "Order")` puts the plain string `'Order'` into `handle.__annotations__`, because the annotation expression literally *is* a string. `typing.get_type_hints(handle)` performs the resolution step: it evaluates each string annotation as an expression using `handle.__globals__` as the globals plus anything you pass as `localns`, normalizes a bare `None` annotation to `type(None)`, strips `Annotated` metadata unless you pass `include_extras=True`, and returns a fresh dict — mutating it cannot corrupt the function. If a name is not reachable from those namespaces you get `NameError`. People quote annotations to name a class defined later in the file, or one imported only for the type checker; `get_type_hints` is what redeems that promise at runtime. Since 3.14 annotations are evaluated lazily (PEP 649), but a quoted annotation is still a string until something resolves it.
code
python · 9 linesfrom typing import get_type_hints
def handle(order: "Order") -> None: ...
print(handle.__annotations__) # {'order': 'Order', 'return': None}
class Order: ...
print(get_type_hints(handle)) # {'order': <class '__main__.Order'>, 'return': <class 'NoneType'>}go deeper
Be ready to say that a quoted annotation is stored as plain text and that typing.get_type_hints is what turns it into the actual class, returning a dict keyed by parameter name plus 'return'.
Explain the mechanics: the string is evaluated as an expression in the defining module's globals, None becomes type(None), Annotated extras are dropped by default, and the result is a new dict rather than the function's own.
Show the production judgement — resolve hints once at registration rather than per request, treat resolution as executing code, and know when the raw string is the right thing to show in a log or an error message.
Own the boundary decision: whether your framework requires runtime-resolvable annotations at all, since that constraint propagates into every consumer's import graph and startup cost.
## Storing an annotation is not evaluating it An annotation is an ordinary Python expression written after a colon. The compiler keeps it associated with the function; it does not interpret its meaning. When the expression you wrote is a string literal — `def handle(order: "Order") -> None:` — the value of that expression is the string `'Order'`, so that is exactly what `handle.__annotations__` contains. Nothing in the interpreter unquotes it. A quoted annotation is a promise to the reader and to the type checker that the name will be resolvable somewhere; `typing.get_type_hints` is the function that cashes the promise at runtime. ## What get_type_hints does for a function Given a function, `get_type_hints` performs a fixed pipeline: 1. It reads the function's raw annotations. 2. Any value that is a `str` is treated as source code for a type expression, wrapped in a `typing.ForwardRef` and evaluated. 3. The evaluation happens in `globalns`, defaulting to `func.__globals__` — the module dict of the module where the function was *defined*, not where you call from — and in `localns`, defaulting to nothing. 4. A bare `None` annotation becomes `type(None)`, because `None` is typing's shorthand for the NoneType. 5. `Annotated[X, ...]` is reduced to `X` unless you pass `include_extras=True`. 6. A brand-new dict is built and returned. Step 3 is the one people get wrong. The namespace is fixed by where the function lives. If your caller can see `Order` but the defining module cannot, resolution still fails; that is what the `localns` parameter is for. Step 6 matters in framework code: the dict you receive is yours, so a decorator can pop or rewrite entries without touching the function's own annotations. ## Why the string is there in the first place Three ordinary reasons produce quoted annotations: a forward reference to a class defined further down the file; a name imported only inside an `if TYPE_CHECKING:` block, which does not exist at runtime; and a heavyweight import someone did not want to pay for at startup. In every case the author has deliberately deferred the cost or the definition, and has accepted that runtime consumers must resolve it explicitly. ## What changed in 3.14 PEP 649 and PEP 749 changed *when* annotations are evaluated. The compiler now builds a lazily-invoked helper that computes a function's annotations, and `__annotations__` runs it on first access. The practical effects are worth stating precisely, because they are easy to overstate: * An **unquoted** forward reference no longer raises at `def` time. It raises when the annotations are actually evaluated — which may be an ordinary `__annotations__` access, or a `get_type_hints` call. * A **quoted** annotation is unaffected. Deferred evaluation of a string literal still yields the string. You still need `get_type_hints` (or `annotationlib`) to obtain the object. * `from __future__ import annotations`, which turned *every* annotation into a string, is superseded by this mechanism. So on 3.14 the mental model is: `__annotations__` gives you the annotation *expressions' values*; `get_type_hints` gives you *resolved typing objects*, with typing's normalizations applied. ## The neighbouring APIs `inspect.get_annotations(func, eval_str=True)` also evaluates string annotations, but it deliberately does none of typing's normalization: no `None` to `type(None)`, no `Annotated` stripping, no class-hierarchy merging. Use it when you want the raw picture. `annotationlib.get_annotations`, new in 3.14, is the low-level API whose `annotationlib.Format` choices let you ask for evaluated values, for placeholder forward references instead of a `NameError`, or for the annotations back as source text. ## Practical guidance Resolve once, at the boundary. `get_type_hints` re-evaluates on every call and caches nothing, so a framework that inspects a handler on every request is compiling and evaluating expressions in its hot path; resolve at registration time and keep the dict. Remember also that resolution executes code from the annotation text, so it is not something to run over annotations you did not write. And when you only need to *display* an annotation — a log line, an error message, generated documentation — the raw string is often the honest thing to show, and asking for resolution just buys you a `NameError` you do not need.
- Does typing.get_type_hints cache anything or mutate the function?No on both counts. It builds and returns a new dict on every call, and the function's own `__annotations__` are untouched, so a decorator may freely edit the returned dict. Because nothing is cached, calling it per request re-evaluates the annotation expressions each time; frameworks resolve once when a handler is registered and store the result.
- What does typing.get_type_hints do with an annotation written as a bare None?It normalizes it to `type(None)`. `def f() -> None` stores `None` itself in `__annotations__`, but `get_type_hints(f)['return']` is the NoneType class, because typing treats `None` in a type position as shorthand for it. `inspect.get_annotations` does not apply that normalization, which is one visible difference between the two APIs.
A quoted annotation is a forwarding address written on an envelope: the post office stores the text as written, and only when someone actually looks the address up does it become a real house.
saying these in an interview costs you the question
- Claims __annotations__ always holds real type objects
- Thinks the interpreter unquotes string annotations automatically
- Confuses the returned dict with the function's live __annotations__
- Says annotations are evaluated against the caller's namespace
- Believes annotations change runtime behaviour without an explicit call