Skip to content
← All articles

PEP 649/749: the end of quoted forward references

Python 3.14 made annotations lazily evaluated. Three formats — VALUE, FORWARDREF, STRING — a new annotationlib module, and one rule that catches everybody: `from __future__ import annotations` still wins.

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:

  1. 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 walked namespace["__annotations__"] in a metaclass __new__ need FORWARDREF.
  2. __annotate__ is a real attribute now. Anything that copied a function with functools.wraps or built one dynamically has a second piece of state to carry.
  3. 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.