Python annotations are not only for static type checkers. Frameworks use them to build dependency graphs, validators inspect them to derive schemas, and documentation tools render them for humans. That makes annotation introspection part of the runtime behavior of many Python applications.
Python 3.14 changes that behavior substantially. Annotations now use deferred evaluation by default, and the standard library adds annotationlib as the dedicated low-level interface for retrieving them.
The important consequence is that annotation consumers should stop assuming there is one universally correct representation. Sometimes you want actual runtime values. Sometimes unresolved names must remain inspectable. Sometimes you only need readable text and should avoid resolving names entirely.
annotationlib makes those choices explicit.
Understand the Python 3.14 semantic change
Before Python 3.14, ordinary annotations were normally evaluated when a function, class, or module definition executed. This made forward references awkward:
def parse(value: Record) -> None:
...
class Record:
passUnder the old eager default, Record did not exist when parse was defined, so evaluating the annotation could fail with NameError.
Python 3.14 adopts deferred annotation evaluation. Conceptually, Python records how to compute the annotations and waits until a consumer asks for them. By the time the annotation is evaluated, Record may already exist.
def parse(value: Record) -> None:
...
class Record:
pass
print(parse.__annotations__)The resulting annotation can contain the actual Record class even though the class was defined later.
This solves an important forward-reference problem, but it also moves work in time. Code executed by an annotation may now run when annotations are inspected rather than when the annotated object is created.
That matters for correctness, performance, and security.
Prefer annotationlib.get_annotations()
Python’s annotation best-practices documentation recommends using annotationlib.get_annotations() on Python 3.14 and newer instead of reading __annotations__ directly.
from annotationlib import get_annotations
def transform(value: int) -> str:
return str(value)
annotations = get_annotations(transform)
print(annotations["value"] is int)
print(annotations["return"] is str)Using the public helper gives annotation-consuming code one place to express how it wants annotations retrieved. That becomes especially useful because annotationlib supports multiple formats.
Direct access is tempting:
annotations = transform.__annotations__but it couples the consumer to details that have changed across Python’s annotation models. A library that exists specifically to introspect annotations is a better compatibility boundary.
Choose an annotation format deliberately
annotationlib.Format defines the principal representations that consumers can request:
from annotationlib import Format, get_annotationsThe three formats most application code needs to understand are:
Format.VALUEfor evaluated runtime values;Format.FORWARDREFfor values plus proxies for unresolved names;Format.STRINGfor source-like strings.
These are not merely presentation choices. They have different failure modes and are useful for different kinds of software.
Use Format.VALUE when runtime objects are required
VALUE is the default format. It asks Python to evaluate annotation expressions and return their values.
from annotationlib import Format, get_annotations
def save(item: int) -> bool:
return True
annotations = get_annotations(save, format=Format.VALUE)
assert annotations == {
"item": int,
"return": bool,
}This representation is convenient for code that genuinely needs runtime objects. A validator might compare an annotation with a known class, or a framework may pass it into other runtime typing utilities.
However, value evaluation can fail if a referenced name still does not exist:
def publish(event: MissingEvent) -> None:
...Requesting VALUE before MissingEvent becomes resolvable can raise an exception. A general-purpose annotation scanner therefore should not blindly assume that all annotations can be fully evaluated.
Preserve unresolved names with Format.FORWARDREF
FORWARDREF is useful when an annotation consumer needs as much resolved information as possible without requiring every referenced name to exist.
from annotationlib import Format, ForwardRef, get_annotations
def publish(event: EventNotLoadedYet) -> None:
...
annotations = get_annotations(
publish,
format=Format.FORWARDREF,
)
event_type = annotations["event"]
assert isinstance(event_type, ForwardRef)Defined names can still be returned as actual values, while unresolved names are represented by ForwardRef proxies.
This is a useful fit for framework discovery. Imagine a plugin scanner that imports modules and records endpoints before every optional model package has been loaded. Failing the entire scan because one annotation is unresolved may be unnecessarily strict. Keeping the unresolved reference explicit lets a later stage decide whether and when it must be resolved.
Do not silently treat a ForwardRef as if it were a validated runtime type. It represents deferred work, not proof that the referenced object exists or has the expected meaning.
Use Format.STRING for display-oriented consumers
Documentation generators and diagnostic tools often do not need runtime values at all. They need readable annotation text.
from annotationlib import Format, get_annotations
def fetch(ids: list[int]) -> dict[str, int]:
...
annotations = get_annotations(
fetch,
format=Format.STRING,
)
print(annotations)STRING returns source-like representations. This can avoid forcing a display-oriented tool to resolve the complete runtime object graph merely to print a signature.
Do not use the returned text as a stable serialization format. Python’s documentation explicitly allows details such as whitespace normalization and constant optimization to change the exact strings in future versions.
If a cache key, wire protocol, or database record requires long-term stability, define your own canonical representation instead of treating Format.STRING output as a permanent identifier.
Deferred does not mean side-effect free
One of the easiest mistakes is to equate lazy evaluation with safe introspection.
Consider an annotation that calls a function:
def discover_type():
print("annotation evaluated")
return int
def process(value: discover_type()) -> None:
...With deferred evaluation, importing the module can complete without calling discover_type(). But a later request for evaluated annotations can run it.
from annotationlib import get_annotations
get_annotations(process)The annotation lookup is now an execution boundary.
The annotationlib documentation warns that most functionality in the module can execute arbitrary code. This is especially important for tooling that scans plugins or application modules supplied by third parties.
If code is untrusted enough that executing its annotation expressions is unacceptable, importing and introspecting it inside the main privileged process is already a dangerous architecture. Use process isolation, capability restrictions, or another trust boundary appropriate to the application.
Do not use annotation evaluation as validation
An annotation is metadata produced by Python code. Successfully resolving it does not prove that external data satisfies it.
def create_user(age: int) -> None:
...Retrieving int from the annotation does not establish that an incoming JSON field is an integer. A framework can choose to interpret annotations as a validation schema, but the validation behavior belongs to that framework.
Keep the stages explicit:
retrieve annotation
-> interpret supported annotation forms
-> validate application data
-> perform business operationThis separation also makes unsupported typing constructs easier to reject deliberately instead of accidentally accepting them.
Be careful when annotations depend on mutable globals
Deferred evaluation changes when names are looked up.
CurrentType = int
def convert(value: CurrentType) -> None:
...
CurrentType = strIf the annotation has not yet been evaluated, its eventual value can depend on the binding visible when evaluation occurs. Code that mutates annotation-related globals can therefore create surprising results.
A practical rule is to treat names used by annotations like other module-level API definitions: keep them stable after module initialization whenever runtime introspection depends on them.
If an application dynamically rewrites those names, tests should cover annotation access both before and after the rewrite rather than assuming import-time semantics.
Account for from __future__ import annotations
Python 3.14’s new deferred model does not immediately erase the older stringified model.
Modules can still contain:
from __future__ import annotationsThat future import continues to make annotations strings. A library may therefore encounter different annotation behavior across modules even while the entire process runs on Python 3.14.
This is another reason to centralize annotation retrieval. Scattered direct reads of __annotations__ tend to accumulate assumptions about whether values are strings, classes, or typing objects.
If your library supports both older and newer Python releases, define a small compatibility layer and test it against every supported version. typing-extensions also provides a backport of get_annotations() for earlier Python environments.
Avoid mutating __annotations__
Runtime frameworks sometimes try to normalize annotations by editing an object’s annotation dictionary in place:
# Avoid this as a normalization strategy.
handler.__annotations__["payload"] = ResolvedPayloadPython’s annotation best practices recommend avoiding direct mutation of __annotations__.
Instead, retrieve annotations and normalize a copy owned by your framework:
from annotationlib import Format, get_annotations
def normalized_annotations(obj):
annotations = get_annotations(obj, format=Format.FORWARDREF)
return dict(annotations)Your application can then transform its own dictionary without rewriting metadata owned by the function or class.
This also makes caching policy explicit. The framework can cache its normalized representation under its own invalidation rules rather than relying on mutation of interpreter-managed annotation state.
Resolve forward references at the right architectural stage
A ForwardRef can later be evaluated when the necessary namespace is available. But resolving it immediately defeats much of the value of retrieving the forward-reference representation in the first place.
A useful architecture separates discovery from binding:
module discovery
-> retrieve FORWARDREF annotations
-> register definitions
-> resolve references needed for activation
-> validate configuration
-> serve trafficDuring discovery, unresolved references are acceptable. During activation, a framework may require every reference relevant to a route, schema, or dependency to resolve successfully.
This produces clearer failures. Instead of an arbitrary module scan crashing because import order happened to expose an unresolved name, the application can report that a specific component could not be activated because a required annotation remained unresolved.
Keep reflection off latency-sensitive paths
Deferred evaluation can move annotation work from import time to first access. That is useful, but it can also move cost into an undesirable place.
A web framework should usually avoid discovering and resolving handler annotations for the first time halfway through the first production request. Prefer a startup phase that performs the annotation work required to serve traffic:
def initialize_routes(routes):
compiled = []
for route in routes:
annotations = inspect_route(route)
compiled.append(compile_route(route, annotations))
return compiledThen request handling can use the compiled metadata rather than repeatedly introspecting annotations.
Measure before adding caches. Python already has defined caching behavior around ordinary __annotations__ access, while alternate-format retrieval can have different needs. Application-level caches should be justified by workload measurements and should have clear invalidation semantics if code can be reloaded dynamically.
Design libraries around the representation they actually need
Different consumers should select different formats.
A runtime dependency injector may need VALUE because it constructs objects based on concrete classes. A plugin indexer may prefer FORWARDREF so discovery survives unresolved optional dependencies. A documentation generator may prefer STRING because resolving objects provides no benefit for display.
A reusable helper can encode that intent:
from annotationlib import Format, get_annotations
def runtime_annotations(obj):
return get_annotations(obj, format=Format.VALUE)
def discovery_annotations(obj):
return get_annotations(obj, format=Format.FORWARDREF)
def display_annotations(obj):
return get_annotations(obj, format=Format.STRING)These tiny wrappers are more informative than a generic annotations(obj) helper whose evaluation behavior callers have to remember.
Test the failure modes, not only the happy path
Annotation-consuming code needs tests that reflect Python 3.14’s lazy model.
Start with an ordinary resolved annotation:
def handler(value: int) -> str:
...Then add a forward reference that becomes resolvable only after the function definition. Verify that VALUE resolves it at the intended stage.
Add a permanently missing name and verify that FORWARDREF preserves it without pretending it is resolved.
Add an annotation with an observable side effect and verify exactly which application phase triggers evaluation. This catches accidental introspection on latency-sensitive or security-sensitive paths.
Also test a module using from __future__ import annotations if your library supports that mode, and run the suite on every Python version in your declared compatibility range.
For framework code, useful failure-injection cases include:
- an annotation references an optional dependency that is not installed;
- a forward reference is still missing at activation time;
- an annotation expression raises an exception;
- a plugin contains an annotation with side effects;
- string output differs in harmless formatting details;
- a global used by a deferred annotation is rebound before evaluation.
These cases reveal assumptions that simple int and str examples will never expose.
Treat annotation introspection as an API boundary
Python 3.14 makes annotations easier to write because forward references no longer require eager resolution under the default semantics. For consumers of annotations, however, the new model makes one decision more important: when and how should an annotation be evaluated?
annotationlib gives that decision a public API.
Use VALUE when concrete runtime objects are genuinely required. Use FORWARDREF when discovery must tolerate unresolved names. Use STRING when readable source-like output is sufficient. Prefer get_annotations() over direct manipulation of __annotations__, and remember that retrieving annotations can execute code.
The safest design is not the one that resolves every annotation as early as possible. It is the one that chooses the minimum representation required for each stage, makes resolution failures explicit, and keeps annotation execution inside a deliberate trust and lifecycle boundary.