Annotations#
Core Idea#
Type annotations describe expected types for static analysis tools such as Pyright and mypy.
Python does not normally enforce them at runtime.
def calculate_notional(quantity: int, price: float) -> float:
return quantity * price
A type checker can reject incompatible calls, but the Python runtime does not automatically validate the arguments against the annotations.
Evaluation Model#
Since Python 3.14, annotations on functions, classes, and modules are evaluated lazily by default.
An annotation expression is not normally evaluated when the function or class is defined. Python evaluates it when the annotation value is later requested.
For example:
def process_order(order: Order) -> None:
pass
class Order:
pass
Order does not exist when process_order is defined.
This is valid in Python 3.14 because the annotation is evaluated later.
This behavior is based on PEP 649 and PEP 749.
Forward References#
A forward reference refers to a type that is not yet available when the annotation is written.
In Python 3.14:
class Node:
def next_node(self) -> Node | None:
return None
No quoted annotation is required.
Before Python 3.14, code using eager annotation evaluation commonly required:
class Node:
def next_node(self) -> "Node | None":
return None
or:
from __future__ import annotations
Type-Only Imports#
TYPE_CHECKING is True for static type checkers and False during
normal runtime execution.
It can therefore avoid imports needed only for static typing:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pricing import PricingEngine
class Trade:
def price_with(self, engine: PricingEngine) -> float:
return engine.price(self)
PricingEngine is not imported at runtime.
In Python 3.14 this works naturally because annotations are not eagerly evaluated when the class is defined.
Runtime Name Resolution#
Using an object passed to a function does not require its annotated class name to exist at runtime.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from models import OrderStatus
def status_name(status: OrderStatus) -> str:
return status.value
status.value works because status is an actual runtime object.
The code does not perform a runtime lookup of OrderStatus.
In contrast:
if status is OrderStatus.FILLED:
...
requires the name OrderStatus at runtime.
Other runtime usages include:
OrderStatus("filled")
isinstance(status, OrderStatus)
OrderStatus.FILLED
OrderStatus.from_value(value)
These require a normal runtime import.
Runtime Annotation Introspection#
Lazy evaluation does not mean that an undefined annotation name can always be resolved.
Consider:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from models import OrderStatus
def process(status: OrderStatus) -> None:
pass
Defining process is valid in Python 3.14.
However, requesting the actual runtime annotation value may require Python to
resolve OrderStatus.
If the name was never imported at runtime, value evaluation can raise
NameError.
Python 3.14 provides annotationlib for annotation introspection:
from annotationlib import Format, get_annotations
Format.VALUE evaluates annotations to runtime objects:
get_annotations(
process,
format=Format.VALUE,
)
This may raise NameError when a referenced type is unavailable.
Format.FORWARDREF allows unresolved names to remain forward references:
get_annotations(
process,
format=Format.FORWARDREF,
)
Format.STRING returns a textual representation:
get_annotations(
process,
format=Format.STRING,
)
For code that introspects annotations, prefer annotationlib.get_annotations
over accessing __annotations__ directly.
Circular Imports#
Lazy annotations and TYPE_CHECKING can remove import cycles caused only by
type hints.
For example:
models.py
Order
engine.py
ExecutionEngine
If Order only needs ExecutionEngine for an annotation:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from engine import ExecutionEngine
the runtime dependency can be avoided.
This does not solve a genuine runtime circular dependency.
For example:
engine = ExecutionEngine(...)
or:
if isinstance(value, ExecutionEngine):
...
still requires the class at runtime.
Persistent runtime circular imports often indicate that module responsibilities or dependency direction should be reconsidered.
Future Annotations#
from __future__ import annotations predates Python 3.14:
from __future__ import annotations
It implements the PEP 563 model, where annotations are stored in stringified form.
This is different from Python 3.14’s default deferred evaluation.
Conceptually:
Python <= 3.13 default
eager evaluation
Python <= 3.13 + future annotations
stringified annotations
Python >= 3.14 default
lazy evaluation
Python >= 3.14 + future annotations
stringified annotations
Therefore, for a project targeting only Python 3.14 or newer, the future import is normally unnecessary.
It may still appear in codebases supporting older Python versions or relying on its stringified annotation behavior.
Interview Notes#
Python 3.14 changed annotations from eager evaluation to lazy evaluation using PEP 649 and PEP 749.
Forward references therefore usually no longer require quotes or
from __future__ import annotations.
TYPE_CHECKING remains useful for avoiding runtime imports that exist only
for static typing.
However, lazy evaluation postpones name resolution rather than guaranteeing
that every annotation can eventually be resolved. Runtime introspection using
actual annotation values may still raise NameError for types imported only
under TYPE_CHECKING.
from __future__ import annotations is the older PEP 563 stringification
model and should not be confused with Python 3.14’s default lazy annotation
semantics.