skip to content

Why does typing.get_type_hints strip Annotated metadata by default?

level: middleimportance: should knowfreq 30%

answer

  1. Two audiences read the same annotation
  2. The default hands back a usable type
  3. One keyword argument changes the answer
  4. Inner positions are affected too
  5. include_extras keeps the wrapper

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.

solid answer

~40 s

`Annotated[X, meta]` means "the type X, carrying extra metadata that the type system ignores". `typing.get_type_hints` existed long before `Annotated`, and its callers expect usable types, so by default it strips the metadata and returns the bare `X` — recursively, so `dict[str, Annotated[int, ...]]` comes back as `dict[str, int]` too. Passing `include_extras=True` disables that strip and preserves every `Annotated` wrapper wherever it appears in the hint. That flag is the whole reason runtime frameworks can attach validation rules, units or field descriptions to a parameter and still read them back after resolution. You want `include_extras=True` when the metadata *is* your input, and the default when you only care about the type: two different consumers of the same annotations, served by one function.

code

python · 9 lines
python
from typing import Annotated, get_type_hints

def ingest(rows: Annotated[list[int], "max=6800"]) -> None: ...

print(get_type_hints(ingest)["rows"])
# list[int]

print(get_type_hints(ingest, include_extras=True)["rows"])
# typing.Annotated[list[int], 'max=6800']

go deeper

for a junior

Know that an annotation can carry extra metadata alongside the type, and that reading hints back gives you the plain type unless you explicitly ask for the extras to be kept.

for a middle

Be able to name include_extras=True, state that the default reduction is recursive through nested generics, and explain why a backward-compatible default returns plain types.

for a senior

Demonstrate the failure mode you have actually debugged: a framework resolving hints without the flag, seeing an unconstrained type, and validating nothing while appearing to work.

for a principal

Own the convention for metadata objects across the codebase - purpose-built classes rather than bare strings, so several libraries can attach payloads to one annotation and each ignores what it does not own.

## Two consumers, one annotation `Annotated[X, m1, m2]` was designed so that a single annotation can serve two audiences at once. To a static type checker the annotation means precisely `X`; the extra arguments are opaque payload it must ignore. To a runtime consumer — a validation layer, a serializer, a dependency injector, a units library — the payload is the interesting part. `typing.get_type_hints` has to serve both audiences, and it picks a default. Its historical contract, which a great deal of code depends on, is that it returns *types*: things you can compare, subscript, pass to `issubclass` where that makes sense, or render as a signature. If it returned `Annotated` wrappers by default, every pre-existing caller would suddenly be handed an object that is not the type it asked for. So the default strips. ## How the strip actually behaves The reduction is recursive, not top-level only. Every `Annotated[...]` found anywhere inside the hint is replaced by its first argument, and the surrounding generic structure is rebuilt around the result: ```python from typing import Annotated, get_type_hints def ingest(rows: Annotated[list[Annotated[int, "positive"]], "max=6800"]) -> None: ... get_type_hints(ingest)['rows'] # list[int] - both wrappers gone get_type_hints(ingest, include_extras=True)['rows'] # typing.Annotated[list[typing.Annotated[int, 'positive']], 'max=6800'] ``` That recursion is the detail candidates miss. Code that says "I only wrapped the outer type, so the default is fine" is usually right by accident; the moment metadata appears on an inner argument the same rule quietly removes it too. ## What include_extras does not change `include_extras=True` is orthogonal to resolution. It does not make a forward reference resolvable, it does not change which namespaces are consulted, and it does not stop a `NameError`. Annotations are still evaluated exactly as they would be otherwise; the only difference is whether the `Annotated` wrapper survives the final pass. Similarly it does not affect the `None` to `type(None)` normalization, or the merging of a class's inherited annotations. ## Why not just read the raw annotations? If you want the metadata, an obvious shortcut suggests itself: read the object's annotations directly and skip `get_type_hints` entirely. Sometimes that is right — but it gives up three things at once. First, resolution: an annotation written as a quoted string is still a string, and now you own the job of evaluating it in the right namespace. Second, on a class, the hierarchy merge: the raw annotations of a class contain only what its own body declared, so inherited attributes vanish. Third, typing's normalizations. `include_extras=True` is the way to keep the metadata *and* everything `get_type_hints` does for you, which is why runtime frameworks use it rather than hand-rolling their own reader. ## Designing metadata that survives A few practical rules follow from all this. Keep metadata objects hashable and cheap to construct, because they are built at annotation-evaluation time and may be rebuilt whenever hints are resolved. Prefer a small purpose-built class over a bare string or tuple, so consumers can identify their own metadata by type rather than guessing at a convention — several independent libraries can and do attach payloads to the same annotation, and each must ignore what it does not own. And document loudly which of your APIs resolves hints with extras and which without: a framework that reads a parameter's constraints through the default, stripping path will silently see an unconstrained type and validate nothing, which is a failure that looks exactly like success. ## The interview shape The question is usually asked as a two-beat: "what does `get_type_hints` return for an `Annotated` parameter?" followed by "and how do you get the metadata?". The strong answer states the default (bare type), the flag (`include_extras=True`), the recursion (inner positions too), and the reason (a backward-compatible default for callers who want types). The weak answer assumes the metadata is always there and then cannot explain why the framework saw nothing.

  • Does the stripping apply to Annotated nested inside another generic?
    Yes. The reduction walks the whole hint, so `dict[str, Annotated[int, "positive"]]` comes back as `dict[str, int]` under the default and keeps both levels of wrapper under `include_extras=True`. Assuming only the outermost wrapper is affected is the usual way a framework ends up silently seeing an unconstrained type.
  • If you want the metadata, why not just read the raw annotations instead?
    Because you would give up resolution and merging. A quoted annotation is still a string you must evaluate in the right namespace, and a class's own annotations exclude everything inherited from its bases. `include_extras=True` gives you the resolved, merged hints with the metadata still attached, which is exactly what a runtime framework needs.
  • Does include_extras=True change how forward references are resolved?
    No. It is orthogonal to resolution: the same namespaces are consulted, the same `NameError` is raised for an unreachable name, and the same normalizations apply. The flag only decides whether the `Annotated` wrapper survives the final pass over the resolved hint.

saying these in an interview costs you the question

  • Thinks Annotated metadata changes the static type
  • Believes get_type_hints preserves extras by default
  • Assumes stripping applies only at the outermost level
  • Says you must parse the annotation text to get metadata
  • Confuses include_extras with evaluating forward references

context