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.