
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.


