skip to content

get_type_hints Resolution

Turning the annotations stored on a function or class into real type objects, against the right globals and locals. The NameError from an import that only exists for the checker is the classic trap.

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

questions

4

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

level: seniorimportance: must knowfreq 55%

answer

  1. A green type checker and a red runtime
  2. A constant that is False when Python runs
  3. The name was never bound in that module
  4. Which globals the resolver actually uses
  5. localns supplies what the module lacks

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.

solid answer

~50 s

`typing.TYPE_CHECKING` is `False` at runtime and `True` only for a type checker, so anything imported under it exists for the checker and not for the interpreter. The annotation still names it, and `get_type_hints` evaluates annotations in the *defining* module's globals — where that name was never bound — so it raises `NameError` naming the missing symbol. The checker stays green, which is why the failure only shows up when something resolves hints at runtime. Fixes, in order of preference: import the type for real when it is cheap and acyclic; pass `localns={"Order": Order}` (or an explicit `globalns`) from a call site that can see it; move the type into a module both sides can import; or do not force resolution at all and keep the annotations unevaluated when you only need to display them. Swallowing the `NameError` and shipping raw strings hides a real gap.

code

python · 15 lines
python
from typing import TYPE_CHECKING, get_type_hints

if TYPE_CHECKING:
    from decimal import Decimal

def on_payment(amount: "Decimal") -> None: ...

try:
    get_type_hints(on_payment)
except NameError as exc:
    print("unresolved:", exc)

import decimal

print(get_type_hints(on_payment, localns={"Decimal": decimal.Decimal}))

go deeper

for a junior

Remember that typing.TYPE_CHECKING is False while the program runs, so a name imported only under it does not exist at runtime even though the editor and checker resolve it fine.

for a middle

Explain that annotations resolve against the defining module's globals, and show the two mechanical fixes - a real import, or passing the missing name through localns on the get_type_hints call.

for a senior

Demonstrate the diagnosis and the tradeoffs: which fix suits a genuine import cycle, why swallowing the error is worse than failing, and why resolution belongs at registration rather than in the request path.

for a principal

Decide the codebase-wide rule - whether types appearing in runtime-resolved annotations may sit behind a guard at all - since that guard silently imposes a contract on every future consumer of those annotations.

## The shape of the bug A webhook receiver registers handler functions and, at startup, walks the registry calling `typing.get_type_hints` on each one so it can build a payload decoder from each handler's parameter type. Most handlers resolve. One raises `NameError: name 'PaymentEvent' is not defined`, and the module it points at imports `PaymentEvent` perfectly clearly — inside an `if TYPE_CHECKING:` block. `typing.TYPE_CHECKING` is a plain module constant that is `False` when Python runs and that type checkers special-case to `True`. Everything under that guard is, at runtime, dead code. The annotation that names the guarded import is therefore a name with no binding in the module's globals, and since `get_type_hints` evaluates annotations against exactly those globals, resolution fails. The reason this reaches production is that every static signal is green: the checker sees the import and type-checks the handler correctly, code review sees an import statement, and the module imports without error because nothing evaluated the annotation. Only a runtime consumer of the hints triggers it — which on 3.14 can be a bare `__annotations__` access too, since PEP 649 evaluates annotations lazily rather than at `def` time. ## Diagnosing it quickly The traceback names the unresolved symbol; grep for it in the defining module and you will find it under the guard, or defined later in a file where the annotation was quoted. Confirm by checking whether the name is reachable from the defining module at runtime — not from the module doing the resolving, which is the most common wrong turn. Resolution follows the function's own `__globals__`; a name your framework can see is irrelevant unless you pass it in. ## The four honest fixes **Import it for real.** If the module is cheap and there is no cycle, the guard was premature optimization. Delete it. This is the right answer far more often than people expect, and it makes every downstream consumer work without special knowledge. **Supply the namespace at the call site.** `get_type_hints(handler, localns={"PaymentEvent": PaymentEvent})` resolves the name for that call without changing the module's import graph. `globalns` replaces the module globals wholesale, which is blunter; `localns` layers a few names on top and is what you usually want. This is the standard escape hatch when the guard exists to break a genuine import cycle. **Move the type.** If the guard exists because two modules import each other, the type usually belongs in a third module that neither one depends on. That fixes the cycle rather than routing around it, and it removes the guard as a side effect. **Do not force resolution.** If the consumer only needs to render the annotation — a log line, an error message, documentation — the unresolved text is fine and asking for objects buys you a failure you do not need. On 3.14 `annotationlib.get_annotations` exposes formats that return placeholder forward references instead of raising, and a string form, for exactly this class of consumer. What is not a fix: wrapping the call in `try/except NameError` and moving on with raw strings. A decoder built from a hint that silently failed to resolve is a decoder that does nothing, and the request that needed it fails much further away from the cause. ## Where the fix belongs A framework that requires runtime-resolvable annotations should say so and fail loudly at registration, listing the handler and the unresolved name, rather than at first request. Startup is the cheapest place to discover this: every handler is present, no traffic is in flight, and a failed boot is a clear signal. The same walk that resolves the hints is the natural place to validate them. It is also worth deciding once, per codebase, whether `TYPE_CHECKING` guards are allowed on types that appear in annotations that something resolves at runtime. Guards are legitimate — for genuine cycles and genuinely expensive imports — but they impose a contract on every runtime consumer of those annotations, and that contract is invisible at the point where someone adds the guard. ## Caching and concurrency One related trap: because resolution is not cached, teams often memoize the resolved hints in a dict shared by worker threads and fill it lazily on first use. The dict itself is not the problem; the check-then-act pattern around it is, so two workers can resolve the same handler concurrently and a reader can observe a half-populated mapping built key by key. Resolving everything at registration, before workers start, removes the race and the latency spike at once.

  • The receiver caches resolved hints lazily in a dict shared by worker threads. What goes wrong?
    The check-then-act pattern races: two workers can miss the same key and both resolve it, and a reader can observe a mapping that is still being filled key by key. Nothing corrupts the dict itself, but the work is duplicated and partial state is visible. Resolve every handler at registration, before workers start, or guard the fill with a threading.Lock.
  • Is catching the NameError and keeping the raw string an acceptable fix?
    Only when the consumer merely displays the annotation. If anything is built from the hint - a decoder, a validator, an injector - a swallowed NameError produces a component that silently does nothing, and the failure resurfaces far from its cause. A framework that needs resolved hints should fail loudly at registration, naming the handler and the missing symbol.
  • Why does passing globalns from the resolving module not reliably fix it?
    Because globalns replaces the defining module's globals wholesale. Every other name in that annotation - other types, aliases, imported generics - then has to be present in the namespace you supplied, so unrelated hints start failing. Layering the one missing name via localns keeps the module's own globals in play and is the narrower, safer intervention.

The guarded import is a stage direction written for the rehearsal script only: the actors know the character exists, but on opening night nobody walks on stage.

saying these in an interview costs you the question

  • Thinks typing.TYPE_CHECKING is True when a checker is installed
  • Blames the quotes rather than the missing runtime binding
  • Silently swallows the NameError and ships raw strings
  • Assumes the resolver uses the calling module's namespace
  • Believes a green type checker proves runtime resolution works

context

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

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

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