Deferred Annotations
Deferred Annotations
In Python 3.14 (PEP 649 and PEP 749), annotations on functions, classes, and modules are not evaluated when the def or class runs. They sit in a special annotate function and run only when something asks for them. Forward names no longer need quotes. The boring default is: write the hint you mean, leave evaluation alone until a tool (or your own runtime check) needs real types — then use annotationlib.get_annotations with an explicit Format.
Mental model
- Deferred (3.14 default): defining
def seat_for(ticket: Ticket)does not look upTicketyet. Import stays cheap. The name can appear later in the file. - Three formats via
annotationlib.Format:VALUE— evaluate to real objects (old stock behavior). RaisesNameErrorif a name is missing.FORWARDREF— real objects when possible; otherwise anannotationlib.ForwardRefproxy you can inspect.STRING— source-ish text ('Ticket','int'), useful for docs and displays.
- Prefer
get_annotations(obj, format=...)over poking__annotations__by hand. Accessing__annotations__tries to evaluate; with unresolved names it can raise. from __future__ import annotationsstill stringizes annotations (PEP 563). That path remains for compatibility, but 3.14’s deferred model is the default without it. Do not add the future import “just in case” when deferred already covers forward refs.- When you need runtime types (validators, small schema helpers, Pydantic-style edges), ask for
Format.VALUE— oreval_str=Trueonly if you are dealing with stringized annotations from the future import.
Worked examples
Case 1: Forward ref without quotes
Save as forward_ref.py. Ticket is not defined when seat_for is. The definition succeeds. Inspection chooses the format.
# forward_ref.py
from annotationlib import Format, get_annotations
def seat_for(ticket: Ticket) -> int:
return ticket.table
def main() -> None:
print("STRING:", get_annotations(seat_for, format=Format.STRING))
fwd = get_annotations(seat_for, format=Format.FORWARDREF)
print("FORWARDREF ticket:", type(fwd["ticket"]).__name__)
print("FORWARDREF arg:", fwd["ticket"].__forward_arg__)
try:
get_annotations(seat_for, format=Format.VALUE)
except NameError as exc:
print("VALUE:", type(exc).__name__ + ":", exc)
if __name__ == "__main__":
main()Run:
uv run --python 3.14 python forward_ref.pyOutput:
STRING: {'ticket': 'Ticket', 'return': 'int'}
FORWARDREF ticket: ForwardRef
FORWARDREF arg: Ticket
VALUE: NameError: name 'Ticket' is not defined
No quotes around Ticket. STRING and FORWARDREF work. VALUE fails until the name exists in the function’s globals.
Case 2: All three formats on a resolved function
Save as three_formats.py. Every name is already defined. All formats succeed; they just look different.
# three_formats.py
from annotationlib import Format, get_annotations
def label(table: int, note: str) -> str:
return f"table {table}: {note}"
def main() -> None:
for fmt in (Format.VALUE, Format.FORWARDREF, Format.STRING):
print(fmt.name, get_annotations(label, format=fmt))
if __name__ == "__main__":
main()Run:
uv run --python 3.14 python three_formats.pyOutput:
VALUE {'table': <class 'int'>, 'note': <class 'str'>, 'return': <class 'str'>}
FORWARDREF {'table': <class 'int'>, 'note': <class 'str'>, 'return': <class 'str'>}
STRING {'table': 'int', 'note': 'str', 'return': 'str'}
Use VALUE when you need isinstance or constructor checks. Use STRING when you only need to print or document the hint.
Case 3: Class annotation before the name exists
Save as board_forward.py. Same idea on a class attribute. The class body does not need Ticket yet.
# board_forward.py
from annotationlib import Format, get_annotations
class Board:
next_ticket: Ticket
def main() -> None:
print("STRING:", get_annotations(Board, format=Format.STRING))
fwd = get_annotations(Board, format=Format.FORWARDREF)
print("FORWARDREF type:", type(fwd["next_ticket"]).__name__)
print("FORWARDREF arg:", fwd["next_ticket"].__forward_arg__)
try:
get_annotations(Board, format=Format.VALUE)
except NameError as exc:
print("VALUE:", type(exc).__name__ + ":", exc)
if __name__ == "__main__":
main()Run:
uv run --python 3.14 python board_forward.pyOutput:
STRING: {'next_ticket': 'Ticket'}
FORWARDREF type: ForwardRef
FORWARDREF arg: Ticket
VALUE: NameError: name 'Ticket' is not defined
Define Ticket later in the module if you want. After it lives in the module globals, Format.VALUE on Board will resolve.
Case 4: Evaluate when you need types at runtime
Save as validate_ticket.py. A tiny desk validator asks for VALUE only when checking a call. Names are defined, so evaluation is safe.
# validate_ticket.py
from annotationlib import Format, get_annotations
def validate(fn, args: dict):
hints = get_annotations(fn, format=Format.VALUE)
for name, expected in hints.items():
if name == "return":
continue
got = type(args[name])
if got is not expected:
raise TypeError(
f"{name}: want {expected.__name__}, got {got.__name__}"
)
return fn(**args)
def open_ticket(table: int, note: str) -> str:
return f"table {table}: {note}"
def main() -> None:
print(validate(open_ticket, {"table": 12, "note": "rush"}))
try:
validate(open_ticket, {"table": "12", "note": "rush"})
except TypeError as exc:
print(type(exc).__name__ + ":", exc)
if __name__ == "__main__":
main()Run:
uv run --python 3.14 python validate_ticket.pyOutput:
table 12: rush
TypeError: table: want int, got str
Libraries that build models from annotations follow the same rule: request VALUE (or resolve ForwardRefs) at the moment you need constructors — not at import time for every function in the kitchen.
Case 5: The __annotations__ / get_type_hints habit
Save as ann_trap.py. Pre-3.14 code often read __annotations__ or called typing.get_type_hints and expected a dict of live types. With a missing name, both can raise. Ask for STRING or FORWARDREF when you only need to inspect.
# ann_trap.py
from annotationlib import Format, get_annotations
from typing import get_type_hints
def seat_for(ticket: Ticket) -> int:
return 0
def main() -> None:
try:
print(seat_for.__annotations__)
except NameError as exc:
print("__annotations__:", type(exc).__name__ + ":", exc)
try:
print(get_type_hints(seat_for))
except NameError as exc:
print("get_type_hints:", type(exc).__name__ + ":", exc)
print("STRING:", get_annotations(seat_for, format=Format.STRING))
fwd = get_annotations(seat_for, format=Format.FORWARDREF)
print("FORWARDREF ticket:", type(fwd["ticket"]).__name__)
if __name__ == "__main__":
main()Run:
uv run --python 3.14 python ann_trap.pyOutput:
__annotations__: NameError: name 'Ticket' is not defined
get_type_hints: NameError: name 'Ticket' is not defined
STRING: {'ticket': 'Ticket', 'return': 'int'}
FORWARDREF ticket: ForwardRef
The trap is not that deferred evaluation is broken. The trap is assuming every access path still means “give me evaluated types right now.”
The trap
Two related mistakes:
- Assuming
__annotations__is always a quiet dict of types like many 3.10–3.13 codebases expected under stock semantics (or under stringized future annotations). In 3.14, reading it may evaluate — and fail — if names are unresolved. - Stringizing forever with
from __future__ import annotationswhen you do not need it. Deferred evaluation already allows unquoted forward refs. Keeping the future import forces stringized values and makes runtime consumers reach foreval_stror manualeval. Use it only when you are matching older tooling that still expects strings.
# future_still_strings.py
from __future__ import annotations
from annotationlib import Format, get_annotations
def seat_for(ticket: Ticket) -> int:
return 0
class Ticket:
pass
def main() -> None:
print("__annotations__:", seat_for.__annotations__)
print("STRING:", get_annotations(seat_for, format=Format.STRING))
print(
"VALUE eval_str:",
get_annotations(seat_for, format=Format.VALUE, eval_str=True),
)
if __name__ == "__main__":
main()Run:
uv run --python 3.14 python future_still_strings.pyOutput:
__annotations__: {'ticket': 'Ticket', 'return': 'int'}
STRING: {'ticket': 'Ticket', 'return': 'int'}
VALUE eval_str: {'ticket': <class '__main__.Ticket'>, 'return': <class 'int'>}
With the future import, __annotations__ is strings even after Ticket exists. That is deliberate PEP 563 behavior — not the 3.14 deferred default.
The boring rule
- Write forward names without quotes under Python 3.14’s default deferred model.
- Introspect with
annotationlib.get_annotations(obj, format=...)— pickVALUE,FORWARDREF, orSTRINGon purpose. - Use
VALUEonly when names resolve (or when failure is the signal you want). - Prefer
FORWARDREForSTRINGfor docs, boards, and “what did the author write?” tooling. - Do not poke
__annotations__as your first move on 3.14+ code. - Do not add
from __future__ import annotationsunless you truly need stringized annotations for older consumers. - Keep runtime validators thin: evaluate at the edge, not at every import.
Try this
- In
forward_ref.py, defineclass Ticket:at module bottom with atable: intattribute, then callget_annotations(seat_for, format=Format.VALUE)again. Confirm it prints the real class. - In
three_formats.py, annotatenote: Notewithout definingNote. Re-run and watch which formats break. - In
validate_ticket.py, add aseats: intparameter and passseats=4. Confirm the validator still accepts the happy path. - Delete the future import from
future_still_strings.pyand re-run. Compare__annotations__(orget_annotations(..., Format.VALUE)) with and without the import — one sentence on the difference.