For fifteen years, annotations had two possible semantics and you had to pick one per module.
Eager (the default): the expression after the colon is evaluated at
function-definition time and stored in __annotations__. Forward references
are a NameError, so you quote them — -> "Node" — and every runtime
consumer has to call get_type_hints() to resolve the strings back into
objects.
Deferred (from __future__ import annotations, PEP 563): every annotation
is stored as a string, always. No forward-reference problem, no import cost,
and no objects — so every runtime consumer has to call get_type_hints()
anyway, and now it can fail on things that used to work, because the string
has to be re-evaluated in a scope that may no longer contain the names.
Neither is good. PEP 563 was accepted, then had its planned mandatory-by-default step reverted in 2021 after the pydantic and FastAPI ecosystems demonstrated what it would cost them.
What 3.14 actually does
PEP 649 replaces both with lazy evaluation. The annotation expressions are
compiled into a hidden function — __annotate__ — attached to the object.
Nothing is evaluated until something asks for __annotations__, at which
point the function runs and the result is cached.
class Node:
parent: Node # no quotes, no __future__ import, no NameError
children: list[Node]
This works because by the time anyone reads Node.__annotations__, the class
statement has finished executing and the name Node exists. The annotation is
a real object, not a string, and no get_type_hints() round trip is needed.
💡If annotations are evaluated lazily, what happens to a module that annotates with a name it imports inside if TYPE_CHECKING:?
click to reveal
It raises NameError — but only when someone asks.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from heavy import Widget
def f(w: Widget) -> None: ...
Under eager evaluation this was an immediate NameError at definition time,
which is why TYPE_CHECKING imports required from __future__ import annotations or quoting. Under PEP 649 the definition succeeds silently. The
failure moves to the first f.__annotations__ access — which might be in a
DI container three services away, at request time, in production.
This is the single biggest behavioural change: the error did not go away, it moved, and it moved from import time (loud, deterministic, caught by any smoke test) to first-access time (quiet, conditional, caught by whoever is on call).
That is exactly what FORWARDREF format exists to handle — see below.
Three formats
PEP 749 gives the runtime a way to ask for annotations in whichever form it
can cope with. annotationlib.Format has three members:
| Format | You get | Use it when |
|---|---|---|
VALUE |
the evaluated objects | you need real types and all names resolve |
FORWARDREF |
objects where possible, ForwardRef placeholders where a name is missing |
you want partial results instead of an exception |
STRING |
the source text of every annotation | you are generating documentation, stubs or a signature display |
from annotationlib import Format, get_annotations
get_annotations(f) # VALUE — may raise NameError
get_annotations(f, format=Format.FORWARDREF) # never raises on a missing name
get_annotations(f, format=Format.STRING) # {'w': 'Widget'}
STRING is the one that quietly deletes a whole category of hack. Tools that
wanted to display an annotation — Sphinx, help(), API doc generators, IDE
hovers — previously either got an object and had to repr it back into
something readable, or forced from __future__ import annotations on their
users. Now they ask for the source text and get it, without evaluating
anything.
inspect.signature gained a matching annotation_format parameter, so the
same three choices are available through the introspection API people actually
use.
💡Why is FORWARDREF more useful than a try/except NameError around VALUE?
click to reveal
Because the failure is per-annotation, and VALUE is all-or-nothing.
A function with eight parameters, one of which references a name only
available under TYPE_CHECKING, gives you a NameError under VALUE and
nothing else. You have lost the seven annotations that were perfectly
resolvable, and you have no way to find out which one failed short of parsing
the message.
FORWARDREF returns all eight. Seven are real type objects; the eighth is a
ForwardRef you can inspect, report on, or resolve later once the missing
module is imported. A validation library can build a schema for the fields it
understands and raise a precise, actionable error naming exactly the field it
cannot.
This is the general shape of good partial-failure design: return the structure with holes in it, not an exception instead of the structure.
The rule that catches everybody
from __future__ import annotations still works, and it OVERRIDES PEP 649
in that module.
A module with that import gets PEP 563 stringified annotations, exactly as before, on 3.14 and after. PEP 649’s lazy evaluation does not apply.
So a 3.14 codebase can contain both mental models simultaneously, file by
file, and the only way to know which one a given module is using is to look at
the top of it. Do not mix them in your head: if you are debugging why
__annotations__ contains strings, check for the __future__ import before
anything else.
PEP 563 is slated for deprecation after 3.13 reaches end of life, so the
__future__ import has a finite lifetime — but “finite” here means years, and
in the meantime it is a live, supported, subtly different code path.
💡A library supports Python 3.10 through 3.14. Should it add from __future__ import annotations to every module for consistency?
click to reveal
Almost certainly not, and the reason is that consistency across versions is not what the import buys you.
On 3.10 through 3.13 it gives you deferred string annotations. On 3.14 it also
gives you deferred string annotations — but it does so by opting out of the
mechanism the rest of the ecosystem is migrating towards. Your modules stay on
the legacy path while every library that introspects them moves to
annotationlib, and get_annotations(..., format=Format.VALUE) on your module
has to re-evaluate strings in a scope it has to reconstruct.
There is also a concrete cost that predates 3.14: under PEP 563, dataclasses,
pydantic and attrs all have to resolve strings at class-creation time,
which reintroduces the scoping problems the import was supposed to remove.
Local classes and conditionally imported names are the usual casualties.
The pragmatic 2026 answer for a 3.10-to-3.14 library: use it where you need it
(modules with genuine forward-reference cycles or expensive TYPE_CHECKING
imports), not as a blanket policy, and plan to remove it as your floor rises
past 3.13.
Who had to change, and what broke
Every library that reads __annotations__ at runtime had to adapt: pydantic,
dataclasses, attrs, FastAPI, cattrs, typeguard, DI containers, ORM field
mappers, CLI argument builders. The change is not that annotations look
different — it is that func.__annotations__ now evaluates at a different
moment, and can raise NameError where it previously returned a string.
Three concrete migration hazards:
-
Accessing
__annotations__inside a class body. During class creation the class object does not exist yet, so a self-referential annotation cannot be evaluated. Consumers that walkednamespace["__annotations__"]in a metaclass__new__needFORWARDREF. -
__annotate__is a real attribute now. Anything that copied a function withfunctools.wrapsor built one dynamically has a second piece of state to carry. -
Caching. The first
__annotations__access evaluates and caches. If the module’s globals change afterwards — monkeypatching, lazy imports, plugin registration — the cached result does not update.
What did not change
Type checkers were never affected. mypy and pyright read your source and
build their own model of it; they have never executed an annotation
expression, and they resolved forward references from the start. Nothing about
PEP 649 changes what mypy --strict accepts or rejects, and no annotation you
write becomes more or less checkable because of it.
This is worth being explicit about, because the change is often reported as “Python’s type system got lazier”. It did not. The runtime’s handling of annotation objects got lazier. The static story is identical, and if you never introspect annotations at runtime, the only thing PEP 649 gives you is the ability to delete your quotes.
Which, to be fair, is a genuinely nice thing to be able to do.