PyDepth

Type Hints at Run Time

Annotations cost nothing per call and everything at import. Reading __annotations__ takes 24 ns; resolving the same class with get_type_hints takes 3.4 µs, 140x more.

4 min read

Type hints occupy an unusual position: they are syntax the interpreter fully evaluates and then almost entirely ignores. Both halves of that sentence surprise people, and each one causes a different class of bug.

They are evaluated

An annotation is an expression. Writing one runs it, at the moment the def or the class body executes. The harness proves this by annotating a parameter with a type whose __class_getitem__ records that it was called:

class Probe:
    def __class_getitem__(cls, item):
        calls.append(item)
        return cls

def annotated(value: Probe[int]) -> None: ...
$ python3 experiments/typehints/runtime.py
python 3.13.2 on darwin

annotation expressions evaluated  1
__annotations__ type              type
  with `from __future__ import`   0
  __annotations__ type            str

__annotations__ read                    24 ns
typing.get_type_hints()                3.4 µs

call, no annotations                 16.85 ns
call, annotated                      16.78 ns

One call, at definition time, for a function that is never invoked. The stored annotation is the resulting object, not the text — type, in the row above.

This is the mechanism behind a whole family of import errors. A forward reference to a class defined later in the file is a NameError, because the name is looked up while the module is still executing. A hint that imports a heavy module purely to name a type pays that import for every user of the module, including the ones that never call the function.

Except when they are not

from __future__ import annotations changes the compilation of the whole module: annotations become strings and are never evaluated. The second pair of rows shows it — zero calls to the probe, and an __annotations__ entry whose type is str.

That fixes forward references and removes the definition-time cost. It also breaks anything that reads __annotations__ and expects objects, which is not a hypothetical: a library that does if annotation is int will silently stop matching, because the annotation is now "int". The correct way to read annotations has always been typing.get_type_hints(), which resolves strings against the defining module’s namespace. Libraries that took the shortcut work until a user adds one __future__ import at the top of their file.

They cost nothing to call

call, no annotations                 16.85 ns
call, annotated                      16.78 ns

Identical within noise, which is the expected result: annotations are stored on the function object and consulted by nobody. CPython does not check them, does not coerce with them and does not branch on them. Any runtime enforcement — Pydantic, attrs validators, a @typechecked decorator — is code someone chose to run, and its cost is that code’s, not the annotation’s.

They cost at import

The two read paths differ by a factor of 140:

__annotations__ read                    24 ns
typing.get_type_hints()                3.4 µs

__annotations__ is a dictionary attribute. get_type_hints walks the MRO, resolves every string against globals and locals, unwraps Optional, strips Annotated, and rebuilds the mapping. Doing that once is 3.4 µs and does not matter. Doing it once per model at import time, which is what a schema library does, is where a service’s start-up time goes: a codebase with 300 models is a millisecond of pure hint resolution before the first request, plus the typing machinery those resolutions construct.

That figure is also why the frameworks cache it. Pydantic builds a validator per model at class-creation time and never resolves hints again; FastAPI resolves a route’s signature once when the route is registered. If a profile of your start-up shows typing on the stack, the question is what is resolving hints repeatedly rather than once.

The one place the stdlib reads them

@dataclass is the counterexample that explains the split. It reads __annotations__ directly and never resolves anything, because it does not need the type — it needs the field names and their order, which the raw mapping already gives it. A dataclass therefore works identically with and without the __future__ import, and its only interest in the annotation object is one string comparison to spot ClassVar and InitVar.

That is the dividing line for any library that touches hints. If you need the names, read __annotations__ and stay cheap. If you need the types, resolve them once with get_type_hints, cache the result on the class, and never do it again. The libraries that get into trouble are the ones that need the types, read the raw dictionary anyway, and work by coincidence until someone defers their annotations.

What this means in practice

Put the expensive imports a hint needs behind if TYPE_CHECKING: and quote the annotation, or turn on the __future__ import for the module. Both make the type checker just as happy and neither runs the import.

Read annotations with get_type_hints, never by reaching into __annotations__, unless you have decided that your library does not support deferred annotations and have said so.

And do not add a runtime validator because a function is annotated. The annotation is not doing anything. If the value needs checking, something has to check it, and that check is a deliberate cost with a deliberate reason.

Where to stop

All of this is CPython 3.13.2, where annotations are evaluated eagerly unless a module opts out. PEP 649 changes the model in 3.14: annotations are computed lazily on first access, which removes the definition-time cost without turning anything into a string. The from __future__ behaviour above becomes a transitional detail rather than the recommended workaround.

Until the interpreter you deploy on is that one, the eager model is the one your imports are paying for, and it is worth knowing which of your hints are being evaluated for no reason at all.

Frequently asked

Do type hints slow my program down?
Not at call time — the measurement here shows no difference. They cost at definition time, once per module, and again whenever something resolves them with get_type_hints.
Should I use from __future__ import annotations?
It removes the definition-time cost and lets you reference names that do not exist yet. It also turns every annotation into a string, which breaks any library that reads __annotations__ directly instead of resolving it.
What changes in Python 3.14?
PEP 649 replaces both behaviours with lazy evaluation: annotations are computed on first access rather than at definition or never. That was not measured here, which is a 3.13 interpreter.
Share

Related posts

Arrow keys to move, Enter to open.