
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, strips the metadata off Annotated unless you ask to keep
it, 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 machinery is
itself an import, and an import is charged to whoever triggers
it.
That figure is also why the frameworks cache it. Pydantic builds a validator per model at
class-creation time and does not resolve hints again unless a forward reference forces a
model_rebuild(); FastAPI resolves a route’s signature once, when the route is registered.
None of it lands in the request — where a Django request actually spends its
milliseconds is the query count, not the definitions.
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 calls get_type_hints, because it does not need the type — it needs the
field names and their order, which the raw mapping already gives it. Its only interest in the
annotation itself is spotting ClassVar, InitVar and KW_ONLY, and when the annotation is
an object that is an identity test against three known values, not a resolution.
Corrected 2026-08-30. This paragraph originally said a dataclass therefore works
identically with and without the __future__ import, and that the check is a string
comparison. Both are wrong, and they are wrong together. Once the annotation is a string
there is no object to test, so dataclasses falls back to matching the text against the
defining module’s globals — and that fallback cannot see a name bound in the class body. The
last rows of typehints/runtime.py show it:
@dataclass fields, ClassVar aliased in the class body
as written value
deferred registry, value
registry: Alias, where Alias = ClassVar[int] sits in the class body, is a class variable
as written and an ordinary field once deferred. The reading is unchanged — a dataclass wants
names, not types — but “identically” was too strong for a check that has two implementations.
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.
Where this came from
Run it yourself — every figure above came out of these:
- typehints/runtime.py — Annotation reading against get_type_hints, and the import cost of both
Read, rather than assumed:


