Deferred Annotations

Updated

October 31, 2026

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 up Ticket yet. Import stays cheap. The name can appear later in the file.
  • Three formats via annotationlib.Format:
    • VALUE — evaluate to real objects (old stock behavior). Raises NameError if a name is missing.
    • FORWARDREF — real objects when possible; otherwise an annotationlib.ForwardRef proxy 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 annotations still 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 — or eval_str=True only 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.py

Output:

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

Output:

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

Output:

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

Output:

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

Output:

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

  1. 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.
  2. Stringizing forever with from __future__ import annotations when you do not need it. Deferred evaluation already allows unquoted forward refs. Keeping the future import forces stringized values and makes runtime consumers reach for eval_str or manual eval. 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.py

Output:

__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=...) — pick VALUE, FORWARDREF, or STRING on purpose.
  • Use VALUE only when names resolve (or when failure is the signal you want).
  • Prefer FORWARDREF or STRING for 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 annotations unless you truly need stringized annotations for older consumers.
  • Keep runtime validators thin: evaluate at the edge, not at every import.

Try this

  1. In forward_ref.py, define class Ticket: at module bottom with a table: int attribute, then call get_annotations(seat_for, format=Format.VALUE) again. Confirm it prints the real class.
  2. In three_formats.py, annotate note: Note without defining Note. Re-run and watch which formats break.
  3. In validate_ticket.py, add a seats: int parameter and pass seats=4. Confirm the validator still accepts the happy path.
  4. Delete the future import from future_still_strings.py and re-run. Compare __annotations__ (or get_annotations(..., Format.VALUE)) with and without the import — one sentence on the difference.