Every time you write def train(model: nn.Module, lr: float = 3e-4) -> dict[str, float]: in Python, something invisible happens. The interpreter evaluates every single annotation expression right there, at function definition time, even though nobody asked for the type information yet. For most scripts that is fine. For a 50,000-line codebase covered in type hints? You are paying a tax on every import. Python deferred annotations, shipping as the default behavior in Python 3.14 via PEP 649, finally kill that tax. I have been tracking this PEP since Larry Hastings first proposed it in 2021, and now that Python 3.14 is out, let me walk through exactly what changed under the hood, what the real numbers look like, and what it means for your code.
The Annotation Problem Python Has Had Since 3.0
Python introduced function annotations in back in Python 3.0. The semantics were simple: annotation expressions are evaluated at definition time and stored in a dictionary called . That worked fine when annotations were exotic. Then the community adopted type hints, and by 2025, 86% of Python engineers use type hints in production code.
# models.py
class User:
name: str
manager: "Manager" # Forward reference -- must be a string!
class Manager:
team: list[User]
reports_to: "Manager" # Again, a string
At the time Python hits manager: "Manager", the class Manager does not exist yet. If you drop the quotes, you get a NameError. So the entire ecosystem learned a workaround: wrap forward references in strings, or slap from __future__ import annotations at the top of every file. Both approaches have real costs.
The string workaround means __annotations__ contains raw strings instead of actual type objects. Any library that needs to inspect those annotations at runtime -- think Pydantic, dataclasses, FastAPI -- must call eval() on those strings to recover real types. That eval() is fragile, slow, and sometimes impossible to do correctly when closures and local variables are involved.
The from __future__ import annotations directive, introduced by PEP 563 in Python 3.7, made all annotations strings automatically. It improved import performance -- Lukasz Langa's benchmark-annotations project measured 35% faster imports and 35% less memory on heavily annotated modules (from 1.79s to 1.16s and from 143.6 MB to 93.3 MB RSS). But it broke runtime introspection so badly that Pydantic, cattrs, and other major libraries fought it for years. The Steering Council eventually decided PEP 563 would never become the default.
Python needed a third option: don't evaluate annotations eagerly, but don't turn them into strings either. That is exactly what PEP 649 does.
How Deferred Annotations Work Under the Hood
PEP 649, authored by Larry Hastings and finalized on May 8, 2023, introduces a new protocol for annotation storage. Instead of computing annotations at definition time and dumping them into __annotations__, the compiler generates a small function called __annotate__ and attaches it to the function, class, or module object.
Python 3.13 and earlier would immediately evaluate nn.Module, float, dict[str, float], build a dict, and store it as train.__annotations__. Python 3.14 instead compiles a small closure -- roughly equivalent to:
This closure captures the appropriate globals and locals but does not execute until someone actually reads train.__annotations__. The __annotations__ attribute itself becomes a data descriptor on the type: the first time you access it, the descriptor calls __annotate__(1), caches the result, and returns it. Every subsequent access hits the cache.
The format argument is key. PEP 749, the companion implementation PEP, defines four formats via the new annotationlib.Format enum:
Format
Value
What you get
VALUE
1
Actual Python objects (the default)
VALUE_WITH_FAKE_GLOBALS
2
Internal use for forward-reference resolution
FORWARDREF
3
Real objects where defined, ForwardRef proxies otherwise
STRING
4
String representations of annotation source code
The FORWARDREF format is the breakthrough for library authors. Instead of crashing with a NameError when a name is not yet defined, the runtime returns a ForwardRef proxy object that records the name and can be evaluated later. Libraries like Pydantic can now introspect annotations safely without eval() gymnastics:
from annotationlib import get_annotations, Format
class LinkedList:
head: Node # Node is not yet defined
class Node:
value: int
next: LinkedList | None = None
# Safe introspection with forward reference support
annots = get_annotations(LinkedList, format=Format.FORWARDREF)
print(annots)
# {'head': ForwardRef('Node', is_class=True, owner=<class 'LinkedList'>)}
# After Node is defined, VALUE format works too
annots = get_annotations(LinkedList, format=Format.VALUE)
print(annots)
# {'head': <class 'Node'>}
No strings. No eval(). No from __future__ import. The annotations are real code objects that evaluate on demand.
One subtlety: the __annotate__ function is generated by the compiler, not the user. It uses a "fake globals" technique -- a special dictionary where missing key lookups return stringizer proxy objects instead of raising KeyError. These proxies implement every dunder method, so expressions like list[User] produce ForwardRef('list[User]') rather than failing. The compiler marks __annotate__ functions with a special co_flags bit so the runtime knows they support this fake-globals protocol.
The Performance Win: Startup Time and Memory
Let me be concrete about what "lazy" buys you. In the common case -- the vast majority of annotations are never introspected at runtime -- Python 3.14 avoids all the work of evaluating annotation expressions and building annotation dicts.
The Real Python team demonstrated this with a dramatic benchmark. They created a module where annotation expressions compute Fibonacci numbers (an intentionally expensive operation to make the effect visible):
# Python 3.13 (eager evaluation)
$ time -p python3.13 -c 'import fib'
real 1.93
user 1.88
sys 0.06
# Python 3.14 (lazy evaluation)
$ time -p python3.14 -c 'import fib'
real 0.03
user 0.02
sys 0.00
That is a 64x speedup on import. Of course, this is a synthetic worst case -- nobody puts fibonacci(35) in their type hints. But the principle scales. In real annotation-heavy codebases, annotation evaluation involves resolving type subscriptions (dict[str, list[int]]), importing typing constructs, and building parameterized generics. All of that work is now deferred.
Lukasz Langa's benchmark-annotations project provides more realistic numbers. On a module designed to mirror real-world annotation density, imports without from __future__ import annotations took 1.79s and consumed 143.6 MB RSS. With PEP 563's string-based postponement, those numbers dropped to 1.16s and 93.3 MB -- a 35% improvement across the board.
PEP 649 should land somewhere near PEP 563's performance for the "never accessed" case, since it stores a single code object rather than building a dict. But unlike PEP 563, when you do access annotations, you get real objects without paying the eval() penalty. Larry Hastings noted in the PEP that the only case where PEP 563 wins is when you want annotations as strings -- since PEP 563 already has them pre-stringified.
What Changes for Your Code
For most Python developers, the answer is: almost nothing. The change is backward-compatible by design. Your annotations still work. __annotations__ still returns a dict. typing.get_type_hints() still does what it did before.
Here is what does change:
Forward references just work. You no longer need to quote types or use from __future__ import annotations:
# Python 3.14 -- no workarounds needed
from dataclasses import dataclass
@dataclass
class TreeNode:
value: int
left: TreeNode | None = None # Self-reference, no quotes
right: TreeNode | None = None
@dataclass
class Config:
db: DatabasePool # Forward reference, no quotes
class DatabasePool:
max_connections: int = 10
config: Config | None = None # Circular reference, no quotes
This just works because TreeNode and DatabasePool are not resolved at definition time. They are resolved when something reads .__annotations__, by which point both classes exist.
Side effects in annotations are deferred. If your annotations had side effects (please don't), those side effects now happen on first access, not at definition time:
counter = 0
def side_effect() -> int:
global counter
counter += 1
return int
def f(x: side_effect()) -> None:
pass
print(counter) # 0 in Python 3.14, 1 in Python 3.13
f.__annotations__ # Now counter becomes 1
New introspection API. The annotationlib module is the new standard way to read annotations:
from annotationlib import get_annotations, Format
# Get annotations as actual values (default, like __annotations__)
get_annotations(some_function, format=Format.VALUE)
# Get annotations with forward references as ForwardRef objects
get_annotations(some_class, format=Format.FORWARDREF)
# Get annotations as source strings (like PEP 563)
get_annotations(some_module, format=Format.STRING)
inspect.get_annotations() still works as a backward-compatible alias. typing.get_type_hints() continues to handle inheritance and Optional unwrapping. But annotationlib.get_annotations() is the recommended path forward, primarily because importing inspect is expensive (it pulls in a large dependency tree) while annotationlib is purpose-built and lightweight.
Library authors need to update. If you maintain a library that reads __annotations__ directly -- and many do -- you should switch to annotationlib.get_annotations() with Format.FORWARDREF. This gives you real type objects where available and ForwardRef proxies where the name is not yet defined. The ForwardRef class has moved from typing to annotationlib with a cleaner API, including an .evaluate() method that accepts globals, locals, type_params, and owner arguments.
Caching is automatic. The first access to __annotations__ evaluates and caches. Subsequent reads return the cached dict. If you delete __annotations__, it also clears __annotate__ to prevent stale state. This is a deliberate design choice documented in PEP 749.
PEP 649 vs PEP 563: Why from __future__ Lost
The battle between PEP 563 and PEP 649 was one of the longest-running debates in Python's history. I think it is worth understanding why PEP 563 ultimately did not become the default, because the reasons illuminate real design tradeoffs.
PEP 563's approach: Turn all annotations into strings at compile time. The compiler decompiles the AST for each annotation expression back into a string and stores that string. Import time drops because no evaluation happens. Forward references work because everything is a string.
PEP 649's approach: Keep annotations as real code but defer execution. The compiler generates a small function (__annotate__) that returns real objects when called. Import time drops because the function is not called. Forward references work because by the time anyone calls the function, the referenced names exist.
The problem with PEP 563 is that going from string back to object is fundamentally lossy. When you stringize dict[str, list[int]], you get "dict[str, list[int]]". To recover the real dict[str, list[int]] object, you must eval() that string with the right globals and locals. As the PEP 649 text puts it: "trying to evaluate those strings to get the real annotation objects is really hard, perhaps impossible to always do correctly."
Under PEP 563, validate.__annotations__ is {'value': 'field_type', 'return': 'bool'}. But field_type is a closure variable -- it does not exist in the module globals. Calling eval('field_type', globals()) raises NameError. You would need to reconstruct the closure's local scope to recover the real annotation, and Python provides no clean way to do that from outside.
PEP 649 handles this naturally because the __annotate__ function is a closure that captured field_type from the enclosing scope. When called, it evaluates field_type in its own scope and returns the real object.
This is why libraries like Pydantic pushed back so hard against PEP 563 becoming the default. Pydantic v1 relied on typing.get_type_hints() to evaluate string annotations, and it worked most of the time, but broke in enough edge cases to be a constant source of bugs.
For now, from __future__ import annotations still works exactly as before in Python 3.14 -- it stringizes annotations, and __annotate__ returns those strings when called with VALUE format. But the deprecation clock is ticking. Per PEP 749, after Python 3.13 reaches end-of-life, the future import will begin emitting DeprecationWarning, and a later release (at least two versions after the warning starts) will turn it into a SyntaxError.
If you are still using from __future__ import annotations purely for forward references, you can safely remove it on Python 3.14+. If you are using it for performance on a pre-3.14 codebase, keep it until you drop support for Python 3.13 and earlier.
The Road Ahead
PEP 649 landed after a five-year journey. Larry Hastings first proposed it in January 2021, targeting Python 3.10. The Steering Council tentatively accepted it in 2023. The actual implementation, coordinated through PEP 749 by Jelle Zijlstra, reached "Final" status on May 5, 2025 and shipped with Python 3.14.
The immediate impact is that annotation-heavy codebases import faster and forward references stop being a source of bugs. The deeper impact is that it unlocks a design space for runtime type processing that was previously impractical. When annotations are cheap to define and rich to inspect, patterns like runtime validation, serialization schema generation, and dependency injection become more natural.
I think we will see library authors adopt annotationlib.get_annotations() with Format.FORWARDREF as the standard introspection path. Pydantic, FastAPI, and dataclasses-like libraries can stop maintaining workarounds for string annotations. New libraries can assume annotation objects are available without import-time cost.
There is also a related discussion about lazy imports more broadly. PEP 649 proves that deferred evaluation works for annotations. The same principle could apply to module imports -- don't execute the import until the imported name is actually used. That is a harder problem (import side effects are common and sometimes intentional), but the annotation success story makes the case that laziness in Python's runtime can be practical, not just theoretical.
Five years ago, I would have told you that Python's type hint story was fundamentally broken at runtime. The gap between what static type checkers understood and what the runtime could actually provide was widening with every release. PEP 649 closes that gap. Annotations are real code again, they just don't run until you need them. Sometimes the best optimization is not doing the work faster -- it is not doing the work at all.