Python Lazy Annotations: The Complete Guide to Deferred Evaluation

Python 3.14 permanently changed how the interpreter handles annotations. For the first time in Python's history, annotations on functions, classes, and modules are not evaluated the moment the interpreter reads them. Instead, they sit dormant until something actually needs them. This is called lazy evaluation, and understanding it matters whether you write type-annotated application code, build libraries that introspect annotations at runtime, or maintain tools in the typing ecosystem.

The story of lazy annotations is not a simple one. It spans nearly fifteen years of Python development, three competing PEPs, several reversals of official position, and real friction between two major communities within the Python ecosystem: static type checkers and runtime annotation users. To understand why Python 3.14 works the way it does, you need to trace that history.

What Annotations Are (and Are Not)

Python annotations were introduced in two phases. PEP 3107 added function annotations in Python 3.0, and PEP 526 added variable annotations in Python 3.6. Both PEPs were deliberately agnostic about what annotations should mean. They were defined as syntactic metadata you can attach to function parameters, return values, and variables. Nothing more.

The syntax is straightforward:

# Function parameter and return annotations
def greet(name: str, times: int = 1) -> str:
    return name * times

# Variable annotation
user_id: int = 42

# Annotation without assignment (declaration only)
score: float

At runtime, annotations are stored in a dictionary accessible via the __annotations__ attribute on the annotated object. For a function, that means greet.__annotations__ returns a dict like {'name': <class 'str'>, 'times': <class 'int'>, 'return': <class 'str'>}.

Annotations vs. Type Hints

Annotations and type hints are not the same thing. Type hints, defined in PEP 484, are the dominant use case for annotations today. But annotations can hold anything: strings, integers, custom objects, even arbitrary expressions. A runtime library can attach validation rules via annotations just as easily as a type checker can read type information from them. The lazy evaluation changes in Python 3.14 affect all annotations, not only those used for type hints.

Because annotation expressions were historically evaluated immediately when the interpreter processed a function or class definition, the values stored in __annotations__ under stock semantics were live Python objects. That design choice, simple as it seemed in 2006, turned out to generate cascading problems as Python's type system grew more sophisticated.

The Problem With Eager Evaluation

Consider a class that references itself in an annotation, which is a standard pattern when building recursive data structures:

class Node:
    def __init__(self, value: int, next: Node = None):
        self.value = value
        self.next = next

Under eager evaluation, this code raises a NameError at class definition time. When Python processes the __init__ method and reaches the annotation next: Node, the name Node does not yet exist in the enclosing scope because the class body has not finished executing. The annotation is evaluated immediately, finds no object named Node, and raises an error before the class can finish being defined.

This is the forward reference problem. It was manageable when annotations were rare, but as projects adopted comprehensive type hinting, it became a chronic source of frustration. The workaround was to quote the annotation as a string literal:

class Node:
    def __init__(self, value: int, next: 'Node' = None):
        self.value = value
        self.next = next

Quoting an annotation as a string defers evaluation: the string is stored as-is, and anything that needs the actual type can call typing.get_type_hints() to resolve it. But requiring programmers to hand-quote annotations cluttered code, was easy to forget, and produced confusing behavior for anyone reading __annotations__ directly and expecting real objects instead of strings.

A second problem was performance. Even for code that uses annotations purely for static analysis and never touches them at runtime, Python still evaluated every annotation expression at import time. Large codebases with extensive type hints paid a measurable startup cost for annotations they would never read during normal execution.

PEP 563: The String Workaround

PEP 563, authored by Lukasz Langa and accepted for Python 3.7, addressed forward references by converting all annotations to strings automatically at compile time. You could opt in per module using a future import:

from __future__ import annotations

With this directive active, no annotation expression was evaluated at runtime. The compiler serialized each annotation back into its source code representation and stored that string. A function annotated as def f(x: int) -> str would have f.__annotations__ return {'x': 'int', 'return': 'str'} rather than {'x': <class 'int'>, 'return': <class 'str'>}. Forward references worked without quoting. Startup performance improved because no annotation expressions ran during import.

"With the postponed evaluation of annotations (PEP 563) turned on, Python would no longer evaluate them at runtime. Instead, it would immortalize annotations as strings, leaving their interpretation up to you." Bartosz Zaczyński, Real Python, "Python 3.14: Lazy Annotations" (2025)

The original plan was for PEP 563's behavior to become the default in Python 3.10. That plan was reversed, and understanding why reveals the core tension that PEP 649 and PEP 749 eventually had to resolve.

The problem with stringized annotations was not abstract. Libraries and frameworks that operate on annotations at runtime, such as dataclasses, attrs, Pydantic, FastAPI, and SQLModel, needed to convert annotation strings back into actual Python objects to do their work. The standard tool for this was typing.get_type_hints(), which calls eval() internally. But eval() on a stringized annotation requires the correct namespace context to work, and establishing that context reliably across all possible annotation environments turned out to be unexpectedly difficult. There were edge cases around local scopes, class scopes, conditional imports, and TYPE_CHECKING guards that made stringized annotations a fragile foundation for runtime introspection.

PEP 563 Status

PEP 563 was never made the default. Its from __future__ import annotations directive continues to work in Python 3.14 with its existing string behavior. However, as specified in PEP 749, it will be deprecated once Python 3.13 reaches end-of-life (expected 2029) and eventually removed. New code targeting Python 3.14 and later should not rely on it.

PEP 649 and PEP 749: The Real Fix

PEP 649, authored by Larry Hastings, proposed a fundamentally different mechanism. Rather than converting annotations to strings at compile time (PEP 563), it proposed storing the code to compute annotations in a dedicated function that is only called when needed. This is the crucial distinction: PEP 563 throws away the live objects and stores text. PEP 649 preserves the live objects but defers generating them until access is requested.

"This PEP proposes a new and comprehensive third approach for representing and computing annotations. It adds a new internal mechanism for lazily computing annotations on demand, via a new object method called __annotate__." PEP 649, peps.python.org

PEP 649 was accepted and implemented. PEP 749, authored by Jelle Zijlstra, expanded on it and filled in details that PEP 649 left open, including the deprecation plan for PEP 563 and the introduction of the new annotationlib standard library module. Together, PEPs 649 and 749 define what ships in Python 3.14.

The official Python documentation now recognizes three annotation execution models in the history of Python 3:

  1. Stock semantics (Python 3.0 through 3.13, PEP 3107 and PEP 526): annotations are evaluated eagerly at the point of definition.
  2. Stringified annotations (opt-in from Python 3.7 via from __future__ import annotations, PEP 563): annotations are stored as strings only.
  3. Deferred evaluation (default from Python 3.14, PEP 649 and PEP 749): annotations are evaluated lazily, only when accessed.

How Lazy Evaluation Works Mechanically

The core mechanism in PEP 649 is a new dunder attribute called __annotate__. When Python 3.14 compiles a function, class, or module that contains annotations, the compiler writes the annotation expressions into a separate, hidden function instead of evaluating them immediately. This function, stored in __annotate__, accepts a single required argument called format and returns a dictionary mapping annotation names to their values.

The __annotations__ property you have always used is now a computed property rather than a plain dictionary. The first time you access some_function.__annotations__, Python calls some_function.__annotate__(Format.VALUE), caches the resulting dictionary, and returns it. Subsequent accesses return the cached result without re-evaluating anything. This is the lazy part: the annotation code runs once, on first access, and never again.

# Python 3.14 behavior
import sys

def process(data: list[str], limit: int) -> dict[str, int]:
    pass

# __annotate__ holds the deferred annotation function
print(type(process.__annotate__))
# <class 'function'>

# Accessing __annotations__ triggers evaluation and caches it
print(process.__annotations__)
# {'data': list[str], 'limit': <class 'int'>, 'return': dict[str, int]}

# Second access is instant: served from cache
print(process.__annotations__)
# {'data': list[str], 'limit': <class 'int'>, 'return': dict[str, int]}

For forward references, the deferred function approach works cleanly without any quoting. When the annotation code finally runs, the forward-referenced name is already defined in scope:

# Python 3.14: No NameError, no string quoting required
class Node:
    def __init__(self, value: int, next: Node | None = None):
        self.value = value
        self.next = next

# Works: by the time __annotations__ is accessed,
# Node is fully defined
print(Node.__init__.__annotations__)
# {'value': <class 'int'>, 'next': Node | None, 'return': <class 'NoneType'>}

There is an important subtlety to the caching behavior. The annotation function is executed only once. If an annotation expression has a side effect, that side effect fires exactly once when __annotations__ is first accessed, not at definition time. Under eager semantics in Python 3.13 and earlier, the same side effect would have fired at definition time:

# Demonstrates the timing shift
def example(x: print("annotation evaluated")):
    pass

# Python 3.13: prints "annotation evaluated" immediately upon function definition
# Python 3.14: nothing printed yet

example.__annotations__
# Python 3.14: prints "annotation evaluated" NOW, on first access

example.__annotations__
# Python 3.14: nothing printed again; cached result returned

The annotationlib Module

Python 3.14 ships a new standard library module called annotationlib, added as part of PEP 749. It provides the tooling needed to work with annotations reliably in the new deferred evaluation world.

The centerpiece of annotationlib is the get_annotations() function. The official Python documentation designates it as the best practice for fetching annotations from any Python object in Python 3.14 and later, superseding inspect.get_annotations(), which is now deprecated.

import annotationlib

def add(a: int, b: int) -> int:
    return a + b

# Recommended approach in Python 3.14+
print(annotationlib.get_annotations(add))
# {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}

The get_annotations() function accepts a format keyword argument that controls how annotations are returned. This is where the new model becomes genuinely powerful for library authors who need to handle complex annotation scenarios.

Pro Tip

The Python Annotations Best Practices documentation recommends avoiding direct access to __annotations__ on any object and using annotationlib.get_annotations() (Python 3.14+) or inspect.get_annotations() (Python 3.10+) instead. Direct access to __annotations__ has accumulated subtle quirks across versions and is more likely to misbehave with custom metaclasses or unusual object types.

annotationlib also provides two important helper functions for library authors building tools that process annotations partially or during class construction: call_annotate_function() and call_evaluate_function(). The former calls an __annotate__ function in a special environment that supports non-VALUE formats. The latter is analogous but returns a single value, intended for use with lazily evaluated type alias and type variable constructs introduced in Python 3.12.

Annotation Formats in Python 3.14

The Format enumeration in annotationlib exposes four formats for requesting annotations. Each serves a different use case, and understanding the distinction is critical for writing robust annotation-processing code.

from annotationlib import Format
print(list(Format))
# [<Format.VALUE: 1>, <Format.VALUE_WITH_FAKE_GLOBALS: 2>,
#  <Format.FORWARDREF: 3>, <Format.STRING: 4>]

VALUE (1) evaluates the annotations and returns their runtime values. This is the format used when you access __annotations__ directly. It may raise NameError if an annotation references a name that is undefined at evaluation time.

VALUE_WITH_FAKE_GLOBALS (2) is a low-level format intended for library implementors who need to produce annotation values while a class is still being constructed, before all names are necessarily available. It is not intended for general use.

FORWARDREF (3) returns ForwardRef objects for annotations that cannot currently be resolved instead of raising an exception. If the name is defined and resolvable, the actual value is returned. If not, a ForwardRef proxy stands in its place. This is the safest format for introspecting annotations when you cannot guarantee all referenced names exist. As of Python 3.14, the dataclasses module uses FORWARDREF internally when processing class fields.

from annotationlib import get_annotations, Format

class Tree:
    left: Leaf       # Leaf not yet defined
    right: Leaf

# VALUE would raise NameError because Leaf is not defined yet
# FORWARDREF returns proxy objects instead
print(get_annotations(Tree, format=Format.FORWARDREF))
# {'left': ForwardRef('Leaf'), 'right': ForwardRef('Leaf')}

class Leaf:
    value: int

# Now VALUE works cleanly
print(get_annotations(Tree, format=Format.VALUE))
# {'left': <class '__main__.Leaf'>, 'right': <class '__main__.Leaf'>}

STRING (4) returns annotations as their source code string representations, similar to what PEP 563 stored. This is useful when you want a human-readable representation of annotations without evaluating them.

from annotationlib import get_annotations, Format

def transform(source: list[str], limit: int | None = None) -> dict[str, int]:
    pass

print(get_annotations(transform, format=Format.STRING))
# {'source': 'list[str]', 'limit': 'int | None', 'return': 'dict[str, int]'}

Practical Impact on Your Code

For code that simply uses annotations in function signatures and class bodies without inspecting them at runtime, Python 3.14 requires no changes. The forward reference restriction is lifted: you can annotate with names that appear later in the file without quoting them, and everything works.

The from __future__ import annotations directive continues to function exactly as before in Python 3.14. If your codebase already uses it for forward reference compatibility with older Python versions, it will not break. However, for code that targets only Python 3.14 and later, it is no longer needed for that purpose.

Code that reads __annotations__ directly and depends on the values being live objects will find that behavior preserved under the new model. The key difference is timing: what used to happen at definition time now happens at first access. For the overwhelming majority of code this distinction is invisible.

Where you may need to make adjustments is in code that reads __annotations__ and encounters unresolved forward references. Under eager semantics, such a read would fail with a NameError at definition time. Under deferred semantics, it fails with a NameError at the point of access. If your library supports both Python 3.13 and Python 3.14, test the behavior explicitly.

# Robust annotation reading pattern for Python 3.14+
import annotationlib

def read_field_types(cls):
    # Use FORWARDREF to avoid NameError on unresolved names
    annotations = annotationlib.get_annotations(
        cls, format=annotationlib.Format.FORWARDREF
    )
    for field, annotation in annotations.items():
        if isinstance(annotation, annotationlib.ForwardRef):
            print(f"  {field}: unresolved ref to '{annotation.__forward_arg__}'")
        else:
            print(f"  {field}: {annotation}")

The interactive Python REPL in Python 3.14 also respects lazy evaluation. Annotations are deferred in the REPL just as they are in module code. This consistency removes a long-standing source of confusion where identical code could behave differently depending on whether it was pasted into the REPL or executed as a module.

Library and Framework Considerations

Library maintainers need to be aware of a specific class of change. Any code that accesses obj.__annotations__ directly without going through inspect.get_annotations() or annotationlib.get_annotations() may encounter unexpected behavior. The official Python annotations best practices documentation is explicit: avoid accessing __annotations__ directly on any object; use the higher-level helper functions instead.

"If your code reads the __annotations__ attribute on objects, you may want to make changes in order to support code that relies on deferred evaluation of annotations. For example, you may want to use annotationlib.get_annotations() with the FORWARDREF format, as the dataclasses module now does." Python 3.14 What's New, docs.python.org

Libraries like Pydantic, FastAPI, attrs, and SQLAlchemy that perform heavy runtime annotation introspection should audit their annotation-reading code to use annotationlib.get_annotations() with the appropriate format. For most, the FORWARDREF format is the right default: it never raises on unresolved names, and it returns real objects wherever possible.

The inspect.signature() function in Python 3.14 gains a new annotation_format argument, allowing callers to specify which Format they want for parameter annotations. This is useful for introspection tools that want consistent string representations regardless of whether the annotation contains forward references.

import inspect
import annotationlib

def do_work(item: SomeClass, count: int) -> None:
    pass

sig = inspect.signature(
    do_work,
    annotation_format=annotationlib.Format.STRING
)
print(sig)
# (item: SomeClass, count: int) -> None

For packages that need to support both Python 3.13 (and earlier) and Python 3.14, the typing-extensions package provides a backport of annotationlib.get_annotations() that works on earlier Python versions. This allows a unified annotation-reading code path across the version boundary without conditional imports.

One subtle but important point concerns the interaction between lazy annotations and the if TYPE_CHECKING: guard pattern. Under Python 3.14, annotations referencing names imported only under TYPE_CHECKING will raise a NameError when their annotation value is accessed at runtime, because those names are not actually imported. The FORWARDREF format handles this gracefully by returning ForwardRef objects for unresolvable names instead of raising. Static type checkers, which do see TYPE_CHECKING imports, continue to work normally.

Key Takeaways

  1. Python 3.14 makes lazy annotations the default. Annotation expressions are no longer evaluated at function or class definition time. They are evaluated lazily, on first access, and cached. This change is specified in PEP 649 and implemented in detail by PEP 749.
  2. Forward references no longer require string quoting. Under deferred evaluation, names used in annotations are resolved when the annotation is first accessed, not at definition time. By that point, forward-referenced names typically exist in scope. The 'Node'-style string quoting workaround is obsolete for code targeting Python 3.14 and later.
  3. The __annotate__ dunder is the engine. Python 3.14 stores a compiler-generated function in __annotate__ on every annotated function, class, and module. Accessing __annotations__ invokes this function with Format.VALUE, caches the result, and returns it.
  4. Use annotationlib.get_annotations() for robust introspection. It is the officially recommended replacement for inspect.get_annotations() (which is now deprecated) and handles all annotation formats. Use the FORWARDREF format when you cannot guarantee all referenced names exist at the time of access.
  5. from __future__ import annotations is on its way out. It continues to work in Python 3.14 with its original string-storing behavior, but it is deprecated now that PEP 649 solves the same forward reference problem natively. Expect formal deprecation warnings once Python 3.13 reaches end-of-life in 2029.
  6. Library maintainers should audit direct __annotations__ access. Code that reads obj.__annotations__ without going through the standard helper functions may encounter changed behavior under Python 3.14's deferred model. Switching to annotationlib.get_annotations() with FORWARDREF is the safe migration path.

The shift to lazy annotations is one of the more significant internal changes Python has made to its type annotation machinery in the past decade. It resolves a genuine tension that had festered since PEP 563 was first proposed: the need to serve both static type checkers that never inspect annotations at runtime and runtime frameworks that depend on annotations being real, resolvable Python objects. The PEP 649 design thread is elegant precisely because it avoids forcing either community to compromise. Annotations are stored as deferred code, not as stripped strings, so when you do evaluate them you get real objects. And because they never run unless accessed, startup performance is preserved. The result is a more coherent, consistent language for everyone using annotations regardless of why.

Sources

  1. PEP 649 — Deferred Evaluation Of Annotations Using Descriptors, Larry Hastings, peps.python.org
  2. PEP 749 — Implementing PEP 649, Jelle Zijlstra, peps.python.org
  3. PEP 563 — Postponed Evaluation of Annotations, Lukasz Langa, peps.python.org
  4. annotationlib — Functionality for introspecting annotations, Python 3.14 Standard Library Documentation
  5. Annotations Best Practices, Python 3.14 HOWTO Documentation
  6. What's New In Python 3.14, docs.python.org
  7. Bartosz Zaczyński, "Python 3.14: Lazy Annotations", Real Python, August 2025
  8. "Python 3.14 Released and Other Python News for November 2025", Real Python, November 2025
back to articles