# 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.

- Published: 2026-08-25
- Tags: tooling, frameworks, cpython
- Source: https://pydepth.com/blog/type-hints-at-runtime/
- Language: en-US
- Author: Elliot Sayer

---
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:

```python
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.
